diff --git a/Distribution/Features/Auth/AuthController.cs b/Distribution/Features/Auth/AuthController.cs
index 1209f46..9f7504b 100644
--- a/Distribution/Features/Auth/AuthController.cs
+++ b/Distribution/Features/Auth/AuthController.cs
@@ -2,14 +2,27 @@ using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Auth;
+///
+/// Proxies all authentication traffic from the Next.js frontend to the
+/// external Auth Service. The frontend must never call the Auth Service
+/// directly — all auth goes through this controller.
+///
+/// TODO: Implement endpoints once Auth Service contract is agreed.
+///
[ApiController]
[Route("api/auth")]
-public class AuthController(AuthService service) : ControllerBase
+public class AuthController(IAuthServiceClient authServiceClient) : ControllerBase
{
+ // TODO: replace stub endpoints below with real proxy calls once
+ // the Auth Service API contract is finalised.
+
[HttpPost("login")]
- public async Task Login(LoginRequest request)
+ public Task Login()
{
- var result = await service.LoginAsync(request);
- return result is null ? Unauthorized() : Ok(result);
+ // TODO: accept login request shape from Auth Service contract,
+ // forward to authServiceClient, relay response (token / session) back.
+ return Task.FromResult(StatusCode(501, new { error = "Auth integration not yet implemented." }));
}
+
+ // TODO: add further endpoints as needed (refresh, logout, etc.)
}
diff --git a/Distribution/Features/Auth/AuthDTOs.cs b/Distribution/Features/Auth/AuthDTOs.cs
index bb7d71f..7d124b2 100644
--- a/Distribution/Features/Auth/AuthDTOs.cs
+++ b/Distribution/Features/Auth/AuthDTOs.cs
@@ -1,5 +1,5 @@
namespace Distribution.Features.Auth;
-public record LoginRequest(string Username, string Password);
-
-public record LoginResponse(string Token, Guid UserId, string Username, string RoleName);
+// TODO: Define request/response shapes once the Auth Service contract is agreed.
+// These will be the DTOs passed between the Next.js frontend and this proxy controller,
+// which should mirror (or adapt) whatever the Auth Service expects/returns.
diff --git a/Distribution/Features/Auth/AuthService.cs b/Distribution/Features/Auth/AuthService.cs
index e9f4928..ed74e7f 100644
--- a/Distribution/Features/Auth/AuthService.cs
+++ b/Distribution/Features/Auth/AuthService.cs
@@ -1,24 +1,6 @@
-using Distribution.Features.User;
-using Distribution.Infra.Authorization;
-using Microsoft.AspNetCore.Identity;
-
namespace Distribution.Features.Auth;
-public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService)
-{
- private readonly PasswordHasher _hasher = new();
-
- public async Task LoginAsync(LoginRequest request)
- {
- var user = await userRepo.GetByUsernameAsync(request.Username);
- if (user is null || !user.IsActive)
- return null;
-
- var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
- if (result == PasswordVerificationResult.Failed)
- return null;
-
- var token = tokenService.GenerateToken(user);
- return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty);
- }
-}
+// TODO: This service previously contained local password validation logic.
+// It has been replaced by the IAuthServiceClient connector which proxies
+// to the external Auth Service. Remove this file or repurpose it for any
+// local orchestration logic once the Auth Service contract is defined.
diff --git a/Distribution/Features/Auth/AuthServiceClient.cs b/Distribution/Features/Auth/AuthServiceClient.cs
new file mode 100644
index 0000000..2953534
--- /dev/null
+++ b/Distribution/Features/Auth/AuthServiceClient.cs
@@ -0,0 +1,20 @@
+namespace Distribution.Features.Auth;
+
+///
+/// Typed HttpClient that proxies requests to the external Auth Service.
+/// Registered via AddHttpClient<AuthServiceClient> in Program.cs.
+/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
+///
+/// TODO: Implement methods once Auth Service contract is agreed.
+///
+public class AuthServiceClient(HttpClient http) : IAuthServiceClient
+{
+ // TODO: implement proxy methods, e.g.:
+ //
+ // public async Task LoginAsync(LoginRequest request, CancellationToken ct)
+ // {
+ // var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
+ // if (!response.IsSuccessStatusCode) return null;
+ // return await response.Content.ReadFromJsonAsync(ct);
+ // }
+}
diff --git a/Distribution/Features/Auth/AuthServiceOptions.cs b/Distribution/Features/Auth/AuthServiceOptions.cs
new file mode 100644
index 0000000..5407a59
--- /dev/null
+++ b/Distribution/Features/Auth/AuthServiceOptions.cs
@@ -0,0 +1,11 @@
+namespace Distribution.Features.Auth;
+
+public class AuthServiceOptions
+{
+ public const string SectionName = "AuthService";
+
+ /// Base URL of the external Auth Service, e.g. "https://auth.internal".
+ public string BaseUrl { get; set; } = string.Empty;
+
+ // TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
+}
diff --git a/Distribution/Features/Auth/IAuthServiceClient.cs b/Distribution/Features/Auth/IAuthServiceClient.cs
new file mode 100644
index 0000000..a92a22a
--- /dev/null
+++ b/Distribution/Features/Auth/IAuthServiceClient.cs
@@ -0,0 +1,16 @@
+namespace Distribution.Features.Auth;
+
+///
+/// Connector to the external Auth Service. This backend proxies all auth
+/// traffic from the Next.js frontend through to the Auth Service — the
+/// frontend never calls the Auth Service directly.
+///
+/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
+///
+public interface IAuthServiceClient
+{
+ // TODO: define methods based on Auth Service contract, e.g.:
+ // Task LoginAsync(LoginRequest request, CancellationToken ct);
+ // Task RefreshAsync(string refreshToken, CancellationToken ct);
+ // Task LogoutAsync(string token, CancellationToken ct);
+}
diff --git a/Distribution/Program.cs b/Distribution/Program.cs
index a2f48a2..c6b6803 100644
--- a/Distribution/Program.cs
+++ b/Distribution/Program.cs
@@ -91,7 +91,6 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
-builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -117,7 +116,6 @@ builder.Services.AddScoped();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped();
-builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -125,28 +123,57 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddSingleton();
-var jwtSection = builder.Configuration.GetSection("Jwt");
-var jwtKey = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
+// ── Auth Service connector ───────────────────────────────────────────────────
+// TODO: replace placeholder BaseAddress with real Auth Service URL once decided.
+// The AuthServiceOptions:BaseUrl value in appsettings.json drives this.
+builder.Services.Configure(
+ builder.Configuration.GetSection(AuthServiceOptions.SectionName));
-builder.Services.AddAuthentication(options =>
- {
- options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
- options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
- })
- .AddJwtBearer(options =>
- {
- options.TokenValidationParameters = new TokenValidationParameters
+var authServiceBaseUrl = builder.Configuration[$"{AuthServiceOptions.SectionName}:BaseUrl"];
+
+builder.Services.AddHttpClient(client =>
+{
+ // TODO: set BaseAddress once Auth Service URL is known.
+ if (!string.IsNullOrWhiteSpace(authServiceBaseUrl))
+ client.BaseAddress = new Uri(authServiceBaseUrl);
+});
+
+builder.Services.AddScoped();
+
+// ── Authentication ───────────────────────────────────────────────────────────
+// TODO: replace this JWT configuration with the token validation strategy
+// dictated by the Auth Service (symmetric key, JWKS/OIDC, etc.) once decided.
+var jwtSection = builder.Configuration.GetSection("Jwt");
+var jwtKey = jwtSection["Key"];
+
+if (!string.IsNullOrWhiteSpace(jwtKey))
+{
+ builder.Services.AddAuthentication(options =>
{
- ValidateIssuer = true,
- ValidIssuer = jwtSection["Issuer"],
- ValidateAudience = true,
- ValidAudience = jwtSection["Audience"],
- ValidateIssuerSigningKey = true,
- IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
- ValidateLifetime = true,
- ClockSkew = TimeSpan.FromMinutes(1)
- };
- });
+ options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
+ })
+ .AddJwtBearer(options =>
+ {
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = jwtSection["Issuer"],
+ ValidateAudience = true,
+ ValidAudience = jwtSection["Audience"],
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromMinutes(1)
+ };
+ });
+}
+else
+{
+ // Auth Service integration not yet configured — all [Authorize] checks will
+ // fail until the token validation strategy is wired up.
+ builder.Services.AddAuthentication();
+}
builder.Services.AddAuthorization();
diff --git a/Distribution/appsettings.json b/Distribution/appsettings.json
index e217f92..0f035e0 100644
--- a/Distribution/appsettings.json
+++ b/Distribution/appsettings.json
@@ -17,5 +17,8 @@
},
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
+ },
+ "AuthService": {
+ "BaseUrl": ""
}
}
diff --git a/sharedDocs/BACKEND_API_REFERENCE.md b/sharedDocs/BACKEND_API_REFERENCE.md
new file mode 100644
index 0000000..31a58a4
--- /dev/null
+++ b/sharedDocs/BACKEND_API_REFERENCE.md
@@ -0,0 +1,2601 @@
+# Distribution ERP — Backend API Reference
+
+> **Stack:** ASP.NET Core (.NET 9) · MySQL (Entity Framework Core)
+> **Base URL:** `https://` (configure per environment)
+> **Swagger UI:** available at `/swagger` in Development mode
+> **Content-Type:** `application/json` for all requests unless noted (`multipart/form-data` for KYC uploads)
+
+---
+
+## Table of Contents
+
+1. [Authentication & JWT](#1-authentication--jwt)
+2. [Authorization Model](#2-authorization-model)
+3. [Global Response Conventions](#3-global-response-conventions)
+4. [Enumerations Reference](#4-enumerations-reference)
+5. [Pagination](#5-pagination)
+6. [Modules](#6-modules)
+ - [Auth](#61-auth)
+ - [Users](#62-users)
+ - [Roles](#63-roles)
+ - [Authorization Checks](#64-authorization-checks)
+ - [Regions](#65-regions)
+ - [Routes](#66-routes)
+ - [Vehicles](#67-vehicles)
+ - [Warehouses](#68-warehouses)
+ - [Stores](#69-stores)
+ - [Store KYC Documents](#610-store-kyc-documents)
+ - [Categories & Subcategories](#611-categories--subcategories)
+ - [Brands](#612-brands)
+ - [Units of Measure (UOMs)](#613-units-of-measure-uoms)
+ - [Items](#614-items)
+ - [Price Lists](#615-price-lists)
+ - [Schemes (Promotions)](#616-schemes-promotions)
+ - [Suppliers](#617-suppliers)
+ - [Purchase Orders (PO)](#618-purchase-orders-po)
+ - [Goods Receipt Notes (GRN)](#619-goods-receipt-notes-grn)
+ - [Purchase Returns](#620-purchase-returns)
+ - [Stock](#621-stock)
+ - [Driver Profiles](#622-driver-profiles)
+ - [Driver Compliance Config](#623-driver-compliance-config)
+ - [Rides](#624-rides)
+ - [Loading Sheets](#625-loading-sheets)
+ - [Delivery Tasks](#626-delivery-tasks)
+ - [Sales](#627-sales)
+ - [Orders](#628-orders)
+ - [Payments](#629-payments)
+ - [Sync (ERP Import)](#630-sync-erp-import)
+7. [Business Flows](#7-business-flows)
+ - [Region → Route → Store Setup](#71-region--route--store-setup)
+ - [Sale Flow](#72-sale-flow-sales-run)
+ - [Delivery Flow](#73-delivery-flow-end-to-end-ride-lifecycle)
+ - [Ride Cancellation Flow](#74-ride-cancellation-flow)
+ - [Payment Flow](#75-payment-flow)
+ - [Purchase Order Flow](#76-purchase-order-flow-procurement)
+ - [Loading Sheet Flow](#77-loading-sheet-flow-detail)
+ - [Stock Flow](#78-stock-flow-how-stock-moves)
+ - [Scheme / Promotion Flow](#79-scheme--promotion-flow)
+
+---
+
+## 1. Authentication
+
+> ⚠️ **YET TO IMPLEMENT**
+>
+> Authentication is handled by a **dedicated external Auth Service** (separate microservice). This backend acts as a **connector/proxy** — the Next.js frontend never calls the Auth Service directly; all auth traffic flows through this backend's `/api/auth/*` endpoints.
+>
+> The exact contract (token format, claims, endpoints, session strategy) is not yet finalized. The `/api/auth` routes are scaffolded in the backend as pass-through stubs pending Auth Service integration.
+>
+> What is decided:
+> - The frontend calls **this backend** for all auth operations, not the Auth Service directly.
+> - This backend forwards the request to the Auth Service and relays the response.
+> - All other endpoints on this backend will validate whatever credential the Auth Service issues (token format TBD).
+>
+> **Do not implement auth in the frontend until this section is updated.**
+
+---
+
+## 2. Authorization Model
+
+The system uses two orthogonal authorization concepts:
+
+### 2.1 Permission-Based Access Control
+
+Every protected endpoint is guarded by a **policy string** (e.g. `"orders.view"`). The JWT's `role_id` claim is used to look up the role's granted permissions in the database. The frontend should gracefully hide/disable UI elements the user lacks permission for (use the `/api/authorization/permissions/{code}` endpoint to check at runtime).
+
+#### Full Permission Code List
+
+| Permission Code | What it Controls |
+|---|---|
+| `items.view` | Read items, brands, UOMs |
+| `items.create` | Create items, brands, UOMs; also gate for Sync endpoints |
+| `items.update` | Update items, brands, UOMs |
+| `items.delete` | Delete items, brands, UOMs |
+| `categories.view` | Read categories/subcategories |
+| `categories.create` | Create categories/subcategories |
+| `categories.update` | Update categories/subcategories |
+| `categories.delete` | Delete categories/subcategories |
+| `regions.view` | Read regions |
+| `regions.create` | Create regions |
+| `regions.update` | Update regions |
+| `regions.delete` | Delete regions |
+| `routes.view` | Read routes |
+| `routes.create` | Create routes |
+| `routes.update` | Update routes |
+| `routes.delete` | Delete routes |
+| `vehicles.view` | Read vehicles |
+| `vehicles.create` | Create vehicles |
+| `vehicles.update` | Update vehicles |
+| `vehicles.delete` | Delete vehicles |
+| `warehouses.view` | Read warehouses |
+| `warehouses.create` | Create warehouses |
+| `warehouses.update` | Update warehouses |
+| `warehouses.delete` | Delete warehouses |
+| `stores.view` | Read stores, visit history, dashboard |
+| `stores.create` | Create stores |
+| `stores.update` | Update stores, upload KYC docs |
+| `stores.delete` | Delete stores |
+| `stores.credit.update` | Update credit limit / outstanding balance |
+| `stores.kyc.verify` | Change a store's KYC status |
+| `orders.view` | Read orders, sales |
+| `orders.create` | Create orders, sales |
+| `orders.update` | Update orders |
+| `orders.delete` | Delete orders |
+| `users.view` | Read users |
+| `users.create` | Create users |
+| `users.update` | Update users |
+| `users.assign_role` | Change a user's role |
+| `users.assign_scope` | Set a user's region/route/store scope |
+| `users.deactivate` | Deactivate users |
+| `driver_profiles.view` | Read driver profiles |
+| `driver_profiles.create` | Create driver profiles |
+| `driver_profiles.update` | Update driver profiles |
+| `driver_profiles.deactivate` | Deactivate driver profiles |
+| `compliance.manage` | Read/update driver compliance config |
+| `rides.view` | Read rides, cancel requests |
+| `rides.create` | Create rides |
+| `rides.manage` | Assign driver, open, activate, close rides |
+| `rides.cancel.request` | Raise a cancel request on a ride |
+| `rides.cancel.approve` | Approve or reject a cancel request |
+| `loading.view` | Read loading sheets |
+| `loading.dispatch` | Create loading sheets, set items, confirm dispatch |
+| `loading.receive` | Set receipt items, confirm receipt |
+| `loading.resolve` | Resolve discrepancy on a loading sheet |
+
+### 2.2 Scope-Based Data Filtering
+
+Users can be restricted to specific **regions**, **routes**, or **stores**. Certain read operations (orders/sales by store, etc.) enforce this scope. Use the scope-check endpoints to determine what data a user can access:
+
+- `GET /api/authorization/scopes/stores/{storeId}` — can the current user access this store?
+- `GET /api/authorization/scopes/routes/{routeId}` — can the current user access this route?
+- `GET /api/authorization/scopes/regions/{regionId}` — can the current user access this region?
+- `GET /api/authorization/scopes/stores` — list all store IDs the current user can access
+
+---
+
+## 3. Global Response Conventions
+
+| HTTP Status | Meaning |
+|---|---|
+| `200 OK` | Success (read / update / action) |
+| `201 Created` | Resource created; `Location` header + body returned |
+| `204 No Content` | Delete successful |
+| `400 Bad Request` | Validation error or invalid state; body: `{ "error": "message" }` |
+| `401 Unauthorized` | Missing or invalid JWT |
+| `403 Forbidden` | Authenticated but access denied (scope) |
+| `404 Not Found` | Resource not found |
+| `409 Conflict` | Business rule violation (duplicate, already exists, etc.) |
+
+All timestamps are **ISO 8601 UTC** strings (e.g. `"2024-01-15T09:30:00Z"`).
+All IDs are **GUIDs** in the format `"3fa85f64-5717-4562-b3fc-2c963f66afa6"`.
+
+---
+
+## 4. Enumerations Reference
+
+### `KycStatus`
+| Value | Description |
+|---|---|
+| `0` / `"Pending"` | Awaiting review |
+| `1` / `"Verified"` | KYC approved |
+| `2` / `"Rejected"` | KYC rejected |
+
+### `LocationType`
+| Value | Description |
+|---|---|
+| `0` / `"Warehouse"` | Stock held in a warehouse |
+| `1` / `"Vehicle"` | Stock loaded on a vehicle |
+
+### `MovementType`
+| Value | Description |
+|---|---|
+| `0` / `"Sale"` | Stock reduced by a sale |
+| `1` / `"CustomerReturn"` | Stock returned by customer |
+| `2` / `"Transfer"` | Moved between locations |
+| `3` / `"Damage"` | Written off as damaged |
+| `4` / `"Adjustment"` | Manual stock correction |
+| `5` / `"Load"` | Vehicle loading event |
+| `6` / `"PurchaseReceipt"` | GRN stock addition |
+| `7` / `"PurchaseReturn"` | Returned to supplier |
+
+### `LoadingSheetStatus`
+| Value | Description |
+|---|---|
+| `0` / `"Pending"` | Created, not yet dispatched |
+| `1` / `"DispatchConfirmed"` | Warehouse confirmed dispatch |
+| `2` / `"ReceiptConfirmed"` | Driver confirmed receipt |
+| `3` / `"DiscrepancyFlagged"` | Quantities differ; needs resolution |
+| `4` / `"Resolved"` | Discrepancy settled |
+
+### `RideType`
+| Value | Description |
+|---|---|
+| `0` / `"Delivery"` | Delivery-only run |
+| `1` / `"Sale"` | Sales run (driver can take orders) |
+
+### `RideStatus`
+| Value | Description |
+|---|---|
+| `0` / `"Draft"` | Created, not yet started |
+| `1` / `"Open"` | Route opened, ready to activate |
+| `2` / `"Active"` | Currently in progress |
+| `3` / `"CancelRequested"` | Cancel requested, pending approval |
+| `4` / `"Cancelled"` | Cancelled |
+| `5` / `"Closed"` | Completed |
+
+### `CancelRequestType`
+| Value | Description |
+|---|---|
+| `0` / `"VehicleBreakdown"` | Vehicle broke down |
+| `1` / `"DriverUnavailable"` | Driver unavailable |
+| `2` / `"RouteIssue"` | Route-related issue |
+| `3` / `"Other"` | Other reason |
+
+### `CancelRequestStatus`
+| Value | Description |
+|---|---|
+| `0` / `"Pending"` | Awaiting approval decision |
+| `1` / `"Approved"` | Cancel request approved |
+| `2` / `"Rejected"` | Cancel request rejected |
+
+### `PurchaseOrderStatus`
+| Value | Description |
+|---|---|
+| `0` / `"Issued"` | PO created, not yet received |
+| `1` / `"PartiallyReceived"` | Some items received via GRN |
+| `2` / `"Received"` | All items received |
+| `3` / `"Closed"` | Manually closed |
+| `4` / `"Cancelled"` | PO cancelled |
+
+### `SaleStatus`
+| Value | Description |
+|---|---|
+| `0` / `"Pending"` | Sale not yet completed |
+| `1` / `"Completed"` | Sale fulfilled |
+| `2` / `"Cancelled"` | Sale cancelled |
+
+### `OrderStatus`
+| Value | Description |
+|---|---|
+| `0` / `"Pending"` | Order awaiting fulfilment |
+| `1` / `"PartiallyFulfilled"` | Some items fulfilled |
+| `2` / `"Fulfilled"` | Fully fulfilled |
+| `3` / `"Cancelled"` | Order cancelled |
+
+### `PaymentStatus`
+| Value | Description |
+|---|---|
+| `0` / `"Pending"` | No payment received |
+| `1` / `"PartiallyPaid"` | Partial payment received |
+| `2` / `"Paid"` | Fully paid |
+
+### `PaymentMethod`
+| Value | Description |
+|---|---|
+| `0` / `"Cash"` | Cash payment |
+| `1` / `"Credit"` | Credit payment |
+
+### `DeliveryTaskStatus`
+| Value | Description |
+|---|---|
+| `0` / `"Pending"` | Not yet actioned |
+| `1` / `"Delivered"` | Fully delivered |
+| `2` / `"PartiallyDelivered"` | Partially delivered |
+| `3` / `"Failed"` | Delivery failed |
+
+### `VehicleType`
+| Value | Description |
+|---|---|
+| `0` / `"Car"` | Car |
+| `1` / `"Truck"` | Truck |
+| `2` / `"Van"` | Van |
+| `3` / `"Motorcycle"` | Motorcycle |
+| `4` / `"Bicycle"` | Bicycle |
+
+### `WareHouseType`
+| Value | Description |
+|---|---|
+| `0` / `"DistributionCenter"` | Distribution center |
+| `1` / `"FulfillmentCenter"` | Fulfilment center |
+| `2` / `"CrossDocking"` | Cross-docking facility |
+| `3` / `"ColdStorage"` | Cold storage |
+| `4` / `"BondedWarehouse"` | Bonded warehouse |
+| `5` / `"AutomatedWarehouse"` | Automated warehouse |
+
+### `SchemeRuleType`
+| Value | Description |
+|---|---|
+| `0` / `"BuyXGetY"` | Buy X quantity of trigger item, get Y free |
+| `1` / `"SlabDiscount"` | Tiered order-value discount |
+| `2` / `"FlatPercent"` | Flat percentage discount |
+| `3` / `"FreeBundle"` | Buy a bundle of required items, get free item |
+
+### `SyncSource` (on Items)
+| Value | Description |
+|---|---|
+| `0` / `"Manual"` | Created directly in this system |
+| `1` / `"Erp"` | Imported via the Sync API |
+
+---
+
+## 5. Pagination
+
+Endpoints that support pagination accept these query parameters:
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `page` | `int` | `1` | 1-based page number |
+| `pageSize` | `int` | `20` | Records per page |
+
+Paginated responses use this wrapper:
+```json
+{
+ "page": 1,
+ "pageSize": 20,
+ "total": 150,
+ "items": [ ... ]
+}
+```
+
+**Endpoints with pagination:** Driver Profiles, Rides, Loading Sheets, Stock Balances, Stock Movements, Ride Cancel Requests.
+
+All other list endpoints return a plain array — **implement client-side pagination or virtual scrolling for large datasets**.
+
+---
+
+## 6. Modules
+
+---
+
+### 6.1 Auth
+
+> ⚠️ **YET TO IMPLEMENT** — See [Section 1](#1-authentication) for context.
+>
+> The `/api/auth` routes are stubs. Shape of requests and responses will be defined once the Auth Service contract is agreed.
+
+**Base path:** `/api/auth`
+
+---
+
+### 6.2 Users
+
+**Base path:** `/api/users`
+**Required:** JWT token
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/users` | `users.view` | List all users |
+| `GET` | `/api/users/{id}` | `users.view` | Get user by ID |
+| `POST` | `/api/users` | `users.create` | Create user |
+| `PUT` | `/api/users/{id}` | `users.update` | Update user full name |
+| `PUT` | `/api/users/{id}/role` | `users.assign_role` | Assign role to user |
+| `PUT` | `/api/users/{id}/scope` | `users.assign_scope` | Set user's data scope |
+| `DELETE` | `/api/users/{id}` | `users.deactivate` | Deactivate user |
+
+#### Create User — `POST /api/users`
+```json
+{
+ "username": "string", // required, unique login name
+ "password": "string", // required, plaintext (hashed server-side)
+ "fullName": "string", // required
+ "roleId": "guid", // required
+ "reportsToUserId": "guid" // optional, parent user in hierarchy
+}
+```
+
+#### Update User — `PUT /api/users/{id}`
+```json
+{ "fullName": "string" }
+```
+
+#### Assign Role — `PUT /api/users/{id}/role`
+```json
+{ "roleId": "guid" }
+```
+
+#### Assign Scope — `PUT /api/users/{id}/scope`
+```json
+{
+ "regionIds": ["guid", ...], // list (can be empty)
+ "routeIds": ["guid", ...],
+ "storeIds": ["guid", ...]
+}
+```
+
+#### User Response Object
+```json
+{
+ "id": "guid",
+ "username": "string",
+ "fullName": "string",
+ "roleId": "guid",
+ "roleName": "string",
+ "reportsToUserId": "guid | null",
+ "isActive": true,
+ "createdAt": "datetime",
+ "updatedAt": "datetime | null"
+}
+```
+
+---
+
+### 6.3 Roles
+
+**Base path:** `/api/roles`
+**Required:** JWT token (no specific permission — any authenticated user)
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/roles` | List all roles |
+| `GET` | `/api/roles/{id}` | Get role by ID |
+| `GET` | `/api/roles/{id}/permissions` | Get permissions assigned to role |
+
+#### Role Response
+```json
+{
+ "id": "guid",
+ "name": "string",
+ "level": 1, // integer hierarchy level; lower = more authority
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+#### Role Permissions Response
+Array of:
+```json
+{
+ "permissionId": "guid",
+ "code": "orders.view", // use this code for Policy checks
+ "description": "string"
+}
+```
+
+---
+
+### 6.4 Authorization Checks
+
+**Base path:** `/api/authorization`
+**Required:** JWT token
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/authorization/permissions/{permissionCode}` | Check if current user's role has a permission |
+| `GET` | `/api/authorization/scopes/stores/{storeId}` | Check if current user can access a store |
+| `GET` | `/api/authorization/scopes/routes/{routeId}` | Check if current user can access a route |
+| `GET` | `/api/authorization/scopes/regions/{regionId}` | Check if current user can access a region |
+| `GET` | `/api/authorization/scopes/stores` | Get all store IDs accessible to current user |
+
+**Permission check response:**
+```json
+{ "hasPermission": true }
+```
+
+**Scope check responses:**
+```json
+{ "hasScope": true } // stores/routes/regions
+{ "storeIds": ["guid", "guid", ...] } // accessible stores list
+```
+
+---
+
+### 6.5 Regions
+
+**Base path:** `/api/regions`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/regions` | `regions.view` | List all regions |
+| `GET` | `/api/regions/{id}` | `regions.view` | Get region by ID |
+| `POST` | `/api/regions` | `regions.create` | Create region |
+| `PUT` | `/api/regions/{id}` | `regions.update` | Update region |
+| `DELETE` | `/api/regions/{id}` | `regions.delete` | Delete region |
+
+**Create/Update request:**
+```json
+{ "name": "string" }
+```
+
+**Response:**
+```json
+{
+ "id": "guid",
+ "name": "string",
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.6 Routes
+
+**Base path:** `/api/routes`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/routes` | `routes.view` | List all routes |
+| `GET` | `/api/routes/{id}` | `routes.view` | Get route by ID |
+| `POST` | `/api/routes` | `routes.create` | Create route |
+| `PUT` | `/api/routes/{id}` | `routes.update` | Update route |
+| `DELETE` | `/api/routes/{id}` | `routes.delete` | Delete route |
+
+**Create/Update request:**
+```json
+{
+ "regionId": "guid", // required
+ "routeName": "string", // required
+ "startPoint": "string", // required
+ "endPoint": "string", // required
+ "length": 12.5 // decimal (kilometers)
+}
+```
+
+**Response:**
+```json
+{
+ "id": "guid",
+ "regionId": "guid",
+ "routeName": "string",
+ "startPoint": "string",
+ "endPoint": "string",
+ "length": 12.5,
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.7 Vehicles
+
+**Base path:** `/api/vehicles`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/vehicles` | `vehicles.view` | List all vehicles |
+| `GET` | `/api/vehicles/{id}` | `vehicles.view` | Get vehicle by ID |
+| `POST` | `/api/vehicles` | `vehicles.create` | Create vehicle |
+| `PUT` | `/api/vehicles/{id}` | `vehicles.update` | Update vehicle |
+| `DELETE` | `/api/vehicles/{id}` | `vehicles.delete` | Delete vehicle |
+
+**Create/Update request:**
+```json
+{
+ "regNumber": "string", // registration number
+ "numberPlate": "string", // display plate
+ "type": "Van" // VehicleType enum
+}
+```
+
+**Response:**
+```json
+{
+ "id": "guid",
+ "regNumber": "string",
+ "numberPlate": "string",
+ "type": "Van",
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.8 Warehouses
+
+**Base path:** `/api/warehouses`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/warehouses` | `warehouses.view` | List all warehouses |
+| `GET` | `/api/warehouses/{id}` | `warehouses.view` | Get warehouse by ID |
+| `POST` | `/api/warehouses` | `warehouses.create` | Create warehouse |
+| `PUT` | `/api/warehouses/{id}` | `warehouses.update` | Update warehouse |
+| `DELETE` | `/api/warehouses/{id}` | `warehouses.delete` | Delete warehouse |
+
+**Create/Update request:**
+```json
+{
+ "name": "string",
+ "location": "string",
+ "type": "DistributionCenter", // WareHouseType enum
+ "vehicleId": "guid | null" // optional, link to a vehicle
+}
+```
+
+**Response:**
+```json
+{
+ "id": "guid",
+ "name": "string",
+ "location": "string",
+ "type": "DistributionCenter",
+ "vehicleId": "guid | null",
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.9 Stores
+
+**Base path:** `/api/stores`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/stores` | `stores.view` | List all stores |
+| `GET` | `/api/stores/{id}` | `stores.view` | Get store by ID |
+| `POST` | `/api/stores` | `stores.create` | Create store |
+| `PUT` | `/api/stores/{id}` | `stores.update` | Update store |
+| `DELETE` | `/api/stores/{id}` | `stores.delete` | Delete store |
+| `PUT` | `/api/stores/{id}/credit-limit` | `stores.credit.update` | Update credit limit |
+| `PUT` | `/api/stores/{id}/outstanding-balance` | `stores.credit.update` | Update outstanding balance |
+| `GET` | `/api/stores/{id}/outstanding-balance` | `stores.view` | Get outstanding balance details |
+| `PUT` | `/api/stores/{id}/kyc/verify` | `stores.kyc.verify` | Verify or reject KYC |
+| `PUT` | `/api/stores/{id}/segment` | `stores.update` | Classify store segment |
+| `GET` | `/api/stores/{id}/visit-history` | `stores.view` | Get store visit history |
+| `GET` | `/api/stores/{id}/schemes/{schemeId}/eligibility` | `stores.view` | Check scheme eligibility |
+| `GET` | `/api/stores/{id}/dashboard` | `stores.view` | Get store dashboard metrics |
+
+#### Create/Update Store
+```json
+{
+ "routeId": "guid",
+ "ownerName": "string",
+ "storeName": "string",
+ "storeAddress": "string",
+ "latitude": 6.927079,
+ "longitude": 79.861244,
+ "oneTimeAmount": 0.00, // one-time registration/setup fee
+ "creditAmount": 0.00 // assigned credit amount
+}
+```
+
+#### Store Response
+```json
+{
+ "id": "guid",
+ "routeId": "guid",
+ "ownerName": "string",
+ "storeName": "string",
+ "storeAddress": "string",
+ "latitude": 6.927079,
+ "longitude": 79.861244,
+ "oneTimeAmount": 0.00,
+ "creditAmount": 0.00,
+ "creditLimit": 50000.00,
+ "outstandingBalance": 12500.00,
+ "segment": "Gold | null", // Gold, Silver, Key Account, High Risk, etc.
+ "kycStatus": "Pending",
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+#### Update Credit Limit — `PUT /api/stores/{id}/credit-limit`
+```json
+{
+ "newLimit": 75000.00,
+ "approverId": "guid" // user ID of the approver
+}
+```
+
+#### Update Outstanding Balance — `PUT /api/stores/{id}/outstanding-balance`
+```json
+{ "newBalance": 10000.00 }
+```
+
+#### Outstanding Balance Response — `GET /api/stores/{id}/outstanding-balance`
+```json
+{
+ "storeId": "guid",
+ "creditLimit": 50000.00,
+ "outstandingBalance": 12500.00,
+ "availableCredit": 37500.00
+}
+```
+
+#### Verify KYC — `PUT /api/stores/{id}/kyc/verify`
+```json
+{
+ "status": "Verified", // KycStatus: Verified or Rejected
+ "notes": "string | null"
+}
+```
+
+#### Classify Segment — `PUT /api/stores/{id}/segment`
+```json
+{ "segment": "Gold" }
+```
+
+#### Visit History Response — `GET /api/stores/{id}/visit-history`
+Array of:
+```json
+{
+ "visitId": "guid",
+ "visitDate": "datetime",
+ "repUserId": "guid",
+ "outcome": "string"
+}
+```
+
+#### Scheme Eligibility — `GET /api/stores/{id}/schemes/{schemeId}/eligibility`
+```json
+{
+ "eligible": true,
+ "reason": "string | null"
+}
+```
+
+#### Store Dashboard — `GET /api/stores/{id}/dashboard?period=monthly`
+`period` query param is a string (e.g. `"daily"`, `"weekly"`, `"monthly"`).
+
+```json
+{
+ "storeId": "guid",
+ "period": "monthly",
+ "orderCount": 12,
+ "totalOrderValue": 145000.00,
+ "lastOrderDate": "datetime | null"
+}
+```
+
+---
+
+### 6.10 Store KYC Documents
+
+**Base path:** `/api/stores/{storeId}/kyc-documents`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/stores/{storeId}/kyc-documents` | `stores.view` | Get all KYC documents for a store |
+| `POST` | `/api/stores/{storeId}/kyc-documents` | `stores.update` | Upload a KYC document |
+
+#### Upload KYC Document — `POST /api/stores/{storeId}/kyc-documents`
+
+> **Content-Type:** `multipart/form-data`
+
+| Form field | Type | Required | Description |
+|---|---|---|---|
+| `documentType` | `string` | Yes | Document type label (e.g. `"NIC"`, `"Business Registration"`) |
+| `file` | `File` | Yes | The document file |
+
+**Response:**
+```json
+{
+ "id": "guid",
+ "storeId": "guid",
+ "documentType": "NIC",
+ "fileReference": "string", // server-side file reference/path
+ "uploadedAt": "datetime"
+}
+```
+
+---
+
+### 6.11 Categories & Subcategories
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/categories` | `categories.view` | List all categories (with subcategories) |
+| `GET` | `/api/categories/{id}` | `categories.view` | Get category by ID |
+| `POST` | `/api/categories` | `categories.create` | Create category |
+| `PUT` | `/api/categories/{id}` | `categories.update` | Update category |
+| `DELETE` | `/api/categories/{id}` | `categories.delete` | Delete category |
+| `POST` | `/api/categories/{categoryId}/subcategories` | `categories.create` | Create subcategory |
+| `PUT` | `/api/subcategories/{id}` | `categories.update` | Update subcategory |
+| `DELETE` | `/api/subcategories/{id}` | `categories.delete` | Delete subcategory |
+
+**Create/Update category or subcategory:**
+```json
+{ "name": "string" }
+```
+
+**Category detail response (GET by ID):**
+```json
+{
+ "id": "guid",
+ "name": "string",
+ "isActive": true,
+ "createdAt": "datetime",
+ "subCategories": [
+ { "id": "guid", "name": "string", "isActive": true, "createdAt": "datetime" }
+ ]
+}
+```
+
+**Category list response (GET all):** Array of above objects.
+
+---
+
+### 6.12 Brands
+
+**Base path:** `/api/brands`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/brands` | `items.view` | List all brands |
+| `GET` | `/api/brands/{id}` | `items.view` | Get brand by ID |
+| `POST` | `/api/brands` | `items.create` | Create brand |
+| `PUT` | `/api/brands/{id}` | `items.update` | Update brand |
+| `DELETE` | `/api/brands/{id}` | `items.delete` | Delete brand |
+
+**Create/Update:** `{ "name": "string" }`
+
+**Response:** `{ "id": "guid", "name": "string", "isActive": true, "createdAt": "datetime" }`
+
+---
+
+### 6.13 Units of Measure (UOMs)
+
+**Base path:** `/api/uoms`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/uoms` | `items.view` | List all UOMs |
+| `GET` | `/api/uoms/{id}` | `items.view` | Get UOM by ID |
+| `POST` | `/api/uoms` | `items.create` | Create UOM |
+| `PUT` | `/api/uoms/{id}` | `items.update` | Update UOM |
+| `DELETE` | `/api/uoms/{id}` | `items.delete` | Delete UOM |
+
+**Create/Update:** `{ "name": "string" }`
+
+**Response:** `{ "id": "guid", "name": "string", "isActive": true, "createdAt": "datetime" }`
+
+---
+
+### 6.14 Items
+
+**Base path:** `/api/items`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/items` | `items.view` | List items (filterable) |
+| `GET` | `/api/items/{id}` | `items.view` | Get item detail |
+| `POST` | `/api/items` | `items.create` | Create item |
+| `PUT` | `/api/items/{id}` | `items.update` | Update item |
+| `DELETE` | `/api/items/{id}` | `items.delete` | Delete item |
+| `PUT` | `/api/items/{id}/uom-conversions` | `items.update` | Replace all UOM conversions |
+
+#### List Items — `GET /api/items`
+
+Optional query filters:
+| Param | Type | Description |
+|---|---|---|
+| `categoryId` | `guid` | Filter by category |
+| `subCategoryId` | `guid` | Filter by subcategory |
+| `brandId` | `guid` | Filter by brand |
+
+#### Create Item — `POST /api/items`
+```json
+{
+ "sku": "string", // required, unique SKU
+ "name": "string", // required
+ "description": "string", // optional
+ "categoryId": "guid", // required
+ "subCategoryId": "guid", // optional
+ "brandId": "guid", // optional
+ "baseUomId": "guid", // required, base unit of measure
+ "salePrice": 150.00, // optional default sale price
+ "conversions": [ // optional UOM conversion rules
+ {
+ "fromUomId": "guid",
+ "toUomId": "guid",
+ "factor": 12.0 // 1 fromUom = factor toUom
+ }
+ ]
+}
+```
+Returns `409 Conflict` if SKU already exists.
+
+#### Update Item — `PUT /api/items/{id}`
+```json
+{
+ "name": "string",
+ "description": "string | null",
+ "categoryId": "guid",
+ "subCategoryId": "guid | null",
+ "brandId": "guid | null",
+ "baseUomId": "guid",
+ "salePrice": 150.00
+}
+```
+
+#### Replace UOM Conversions — `PUT /api/items/{id}/uom-conversions`
+Replaces **all** existing conversions for the item.
+```json
+{
+ "conversions": [
+ { "fromUomId": "guid", "toUomId": "guid", "factor": 12.0 }
+ ]
+}
+```
+
+#### Item Summary Response (list)
+```json
+{
+ "id": "guid",
+ "sku": "string",
+ "name": "string",
+ "categoryId": "guid",
+ "categoryName": "string",
+ "subCategoryId": "guid | null",
+ "subCategoryName": "string | null",
+ "brandId": "guid | null",
+ "brandName": "string | null",
+ "baseUomId": "guid",
+ "baseUomName": "string",
+ "salePrice": 150.00,
+ "syncSource": "Manual | Erp",
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+#### Item Detail Response (single)
+Same as summary plus:
+```json
+{
+ "description": "string | null",
+ "conversions": [
+ {
+ "id": "guid",
+ "fromUomId": "guid",
+ "fromUomName": "string",
+ "toUomId": "guid",
+ "toUomName": "string",
+ "factor": 12.0
+ }
+ ]
+}
+```
+
+---
+
+### 6.15 Price Lists
+
+**Base path:** `/api/price-lists`
+**Required:** JWT token (no specific permission policy — any authenticated user)
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/price-lists` | List price lists (filterable by region) |
+| `GET` | `/api/price-lists/{id}` | Get price list with all items |
+| `POST` | `/api/price-lists` | Create price list |
+| `PUT` | `/api/price-lists/{id}` | Update price list metadata |
+| `DELETE` | `/api/price-lists/{id}` | Delete price list |
+| `PUT` | `/api/price-lists/{id}/items/{sku}` | Upsert an item's price in this list |
+| `DELETE` | `/api/price-lists/{id}/items/{sku}` | Remove an item from this list |
+
+#### List Price Lists — `GET /api/price-lists`
+Optional query: `?regionId={guid}`
+
+#### Create/Update Price List
+```json
+{
+ "name": "string",
+ "regionId": "guid | null", // null = applies to all regions
+ "effectiveFrom": "datetime",
+ "effectiveTo": "datetime"
+}
+```
+Returns `409 Conflict` if overlapping active price list for the same region exists.
+
+#### Upsert Item Price — `PUT /api/price-lists/{id}/items/{sku}`
+`sku` is the item's SKU string in the URL path.
+```json
+{
+ "price": 150.00,
+ "minPrice": 130.00 // optional, null = price is non-negotiable
+}
+```
+
+#### Price List Response
+```json
+{
+ "id": "guid",
+ "name": "string",
+ "regionId": "guid | null",
+ "regionName": "string | null",
+ "effectiveFrom": "datetime",
+ "effectiveTo": "datetime",
+ "isActive": true,
+ "createdAt": "datetime",
+ "items": [
+ {
+ "id": "guid",
+ "itemId": "guid",
+ "sku": "string",
+ "itemName": "string",
+ "price": 150.00,
+ "minPrice": 130.00
+ }
+ ]
+}
+```
+
+---
+
+### 6.16 Schemes (Promotions)
+
+**Base path:** `/api/schemes`
+**Required:** JWT token (no specific permission policy)
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/schemes` | List schemes (optional `?regionId={guid}`) |
+| `GET` | `/api/schemes/{id}` | Get scheme detail |
+| `POST` | `/api/schemes` | Create scheme |
+| `PUT` | `/api/schemes/{id}` | Update scheme metadata |
+| `DELETE` | `/api/schemes/{id}` | Delete scheme |
+| `PUT` | `/api/schemes/{id}/free-goods-rule` | Set free-goods rule |
+| `DELETE` | `/api/schemes/{id}/free-goods-rule` | Clear free-goods rule |
+| `PUT` | `/api/schemes/{id}/bundle-components/{itemId}` | Upsert bundle component |
+| `DELETE` | `/api/schemes/{id}/bundle-components/{itemId}` | Remove bundle component |
+| `GET` | `/api/schemes/eligible?storeId={guid}&orderId={guid}` | Get eligible schemes for a store+order |
+| `GET` | `/api/schemes/{id}/spend` | Get scheme spend analytics |
+| `GET` | `/api/schemes/{id}/roi` | Get scheme ROI analytics |
+| `POST` | `/api/orders/{id}/apply-scheme` | Apply a scheme to an order |
+
+#### Scheme Rule Types
+
+- **`BuyXGetY`**: Store buys `triggerQty` of `triggerItemId`, gets `freeQty` of `freeItemId` for free.
+- **`SlabDiscount`**: Tiered discount based on order value. `ruleConfig` JSON: `{ "tiers": [ { "minOrderAmount": 10000, "discountPercent": 5.0 }, ... ] }` (ordered ascending).
+- **`FlatPercent`**: Flat percentage off. `ruleConfig` JSON: `{ "discountPercent": 10.0 }`.
+- **`FreeBundle`**: Store buys all required bundle components, gets `freeQty` of `freeItemId` free.
+
+#### Create Scheme — `POST /api/schemes`
+```json
+{
+ "name": "string",
+ "ruleType": "SlabDiscount", // SchemeRuleType enum
+ "ruleConfig": "{...}", // JSON string; required for SlabDiscount/FlatPercent, null for BuyXGetY/FreeBundle
+ "regionId": "guid | null", // null = all regions
+ "eligibleSegment": "Gold | null", // null = all segments
+ "dateRangeFrom": "datetime",
+ "dateRangeTo": "datetime",
+ "agentLimitExempt": false, // if true, exempt from agent discount ceiling
+ "baselineExpectedSales": 500000.00, // optional, used for ROI calc
+ // Optional one-shot fields (can also use separate endpoints later):
+ "triggerItemId": "guid | null", // BuyXGetY: item store must buy
+ "triggerQty": 10, // BuyXGetY: quantity trigger
+ "freeItemId": "guid | null", // BuyXGetY/FreeBundle: item given free
+ "freeQty": 2.0, // quantity of free item
+ "bundleComponents": [ // FreeBundle: required purchase items
+ { "itemId": "guid", "qty": 5 }
+ ]
+}
+```
+
+#### Update Scheme — `PUT /api/schemes/{id}`
+```json
+{
+ "name": "string",
+ "ruleConfig": "{...} | null",
+ "regionId": "guid | null",
+ "eligibleSegment": "string | null",
+ "dateRangeFrom": "datetime",
+ "dateRangeTo": "datetime",
+ "agentLimitExempt": false,
+ "baselineExpectedSales": 500000.00
+}
+```
+
+#### Set Free Goods Rule — `PUT /api/schemes/{id}/free-goods-rule`
+```json
+{
+ "triggerItemId": "guid | null", // required for BuyXGetY; ignored for FreeBundle
+ "triggerQty": 10, // required for BuyXGetY; ignored for FreeBundle
+ "freeItemId": "guid", // required
+ "freeQty": 2.0 // required
+}
+```
+
+#### Upsert Bundle Component — `PUT /api/schemes/{id}/bundle-components/{itemId}`
+```json
+{ "qty": 5 }
+```
+
+#### Apply Scheme to Order — `POST /api/orders/{id}/apply-scheme`
+```json
+{ "schemeId": "guid" }
+```
+
+#### Scheme Response
+```json
+{
+ "id": "guid",
+ "name": "string",
+ "ruleType": "BuyXGetY",
+ "ruleConfig": "string | null",
+ "triggerItemId": "guid | null",
+ "triggerItemName": "string | null",
+ "triggerQty": 10,
+ "freeItemId": "guid | null",
+ "freeItemName": "string | null",
+ "freeQty": 2.0,
+ "bundleComponents": [
+ { "itemId": "guid", "sku": "string", "itemName": "string", "qty": 5 }
+ ],
+ "regionId": "guid | null",
+ "regionName": "string | null",
+ "eligibleSegment": "Gold | null",
+ "dateRangeFrom": "datetime",
+ "dateRangeTo": "datetime",
+ "agentLimitExempt": false,
+ "baselineExpectedSales": 500000.00,
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+#### Eligible Schemes Response — `GET /api/schemes/eligible?storeId=...&orderId=...`
+Array of:
+```json
+{
+ "schemeId": "guid",
+ "schemeName": "string",
+ "ruleType": "SlabDiscount",
+ "eligible": true,
+ "reason": "string | null",
+ "discountAmount": 1500.00,
+ "freeItemId": "guid | null",
+ "freeQty": 2.0
+}
+```
+
+#### Scheme Spend — `GET /api/schemes/{id}/spend`
+```json
+{
+ "schemeId": "guid",
+ "totalDiscountSpend": 15000.00,
+ "totalFreeGoodsCostSpend": 3000.00,
+ "totalSpend": 18000.00,
+ "redemptionCount": 45
+}
+```
+
+#### Scheme ROI — `GET /api/schemes/{id}/roi`
+```json
+{
+ "schemeId": "guid",
+ "baselineExpectedSales": 500000.00,
+ "actualSales": 650000.00,
+ "incrementalSales": 150000.00,
+ "totalSpend": 18000.00,
+ "roi": 7.33 // (incrementalSales - totalSpend) / totalSpend; null if baseline not set
+}
+```
+
+---
+
+### 6.17 Suppliers
+
+**Base path:** `/api/suppliers`
+**Required:** JWT token (no specific permission policy)
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/suppliers` | List all suppliers |
+| `GET` | `/api/suppliers/{id}` | Get supplier by ID |
+| `POST` | `/api/suppliers` | Create supplier |
+| `PUT` | `/api/suppliers/{id}` | Update supplier |
+| `DELETE` | `/api/suppliers/{id}` | Delete supplier (returns `409` if has active POs) |
+| `POST` | `/api/suppliers/{id}/evaluate` | Trigger supplier performance evaluation |
+
+**Create/Update:**
+```json
+{
+ "name": "string",
+ "contactInfo": "string | null"
+}
+```
+
+**Response:**
+```json
+{
+ "id": "guid",
+ "name": "string",
+ "contactInfo": "string | null",
+ "performanceScore": 4.5, // decimal | null; computed by evaluate
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.18 Purchase Orders (PO)
+
+**Base path:** `/api/purchase-orders`
+**Required:** JWT token (no specific permission policy)
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/purchase-orders` | List POs (optional filters) |
+| `GET` | `/api/purchase-orders/{id}` | Get PO detail |
+| `POST` | `/api/purchase-orders` | Create PO |
+| `PUT` | `/api/purchase-orders/{id}` | Update PO lines (while Issued) |
+| `POST` | `/api/purchase-orders/{id}/close` | Manually close PO |
+| `POST` | `/api/purchase-orders/{id}/cancel` | Cancel entire PO |
+| `POST` | `/api/purchase-orders/{id}/items/{itemId}/cancel` | Cancel a single PO line |
+| `POST` | `/api/purchase-orders/{id}/match-invoice` | Match PO to GRN/Invoice |
+| `POST` | `/api/purchase-orders/{id}/resolve-variance` | Resolve invoice variance |
+
+#### List POs — `GET /api/purchase-orders`
+Optional query filters:
+| Param | Type | Description |
+|---|---|---|
+| `supplierId` | `guid` | Filter by supplier |
+| `status` | `PurchaseOrderStatus` | Filter by status |
+
+#### Create PO — `POST /api/purchase-orders`
+```json
+{
+ "supplierId": "guid",
+ "items": [
+ {
+ "itemId": "guid",
+ "quantity": 100,
+ "unitCost": 85.00
+ }
+ ]
+}
+```
+
+#### Update PO — `PUT /api/purchase-orders/{id}`
+Only valid while status is `Issued`.
+```json
+{
+ "items": [
+ {
+ "purchaseOrderItemId": "guid | null", // null = new line, guid = update existing
+ "itemId": "guid",
+ "quantity": 120,
+ "unitCost": 83.00
+ }
+ ]
+}
+```
+
+#### Cancel PO — `POST /api/purchase-orders/{id}/cancel`
+```json
+{ "reason": "string" }
+```
+
+#### Cancel PO Line — `POST /api/purchase-orders/{id}/items/{itemId}/cancel`
+`itemId` here is the `PurchaseOrderItem.Id` (not the item's SKU ID).
+```json
+{ "reason": "string | null" }
+```
+
+#### Match Invoice — `POST /api/purchase-orders/{id}/match-invoice`
+Query params: `?invoiceId={guid}&grnId={guid}` (both optional)
+
+**Response:**
+```json
+{
+ "purchaseOrderId": "guid",
+ "goodsReceiptId": "guid | null",
+ "invoiceId": "guid | null",
+ "isMatched": true,
+ "lines": [
+ {
+ "purchaseOrderItemId": "guid",
+ "itemId": "guid",
+ "orderedQuantity": 100,
+ "acceptedQuantity": 98,
+ "unitCost": 85.00,
+ "quantityMatches": false
+ }
+ ],
+ "note": "string"
+}
+```
+
+#### Resolve Variance — `POST /api/purchase-orders/{id}/resolve-variance`
+```json
+{
+ "variance": "string", // description of the variance
+ "resolution": "string", // resolution action taken
+ "adjustedUnitCost": 84.00, // optional adjusted cost
+ "purchaseOrderItemId": "guid" // optional, which line was adjusted
+}
+```
+
+**Response:**
+```json
+{
+ "purchaseOrderId": "guid",
+ "variance": "string",
+ "resolution": "string",
+ "resolvedAt": "datetime"
+}
+```
+
+#### PO Response
+```json
+{
+ "id": "guid",
+ "supplierId": "guid",
+ "supplierName": "string",
+ "createdByUserId": "guid",
+ "status": "Issued",
+ "cancelReason": "string | null",
+ "isActive": true,
+ "createdAt": "datetime",
+ "items": [
+ {
+ "id": "guid",
+ "itemId": "guid",
+ "sku": "string",
+ "itemName": "string",
+ "quantity": 100,
+ "unitCost": 85.00,
+ "receivedQuantity": 50,
+ "cancelledQuantity": 0,
+ "isCancelled": false
+ }
+ ]
+}
+```
+
+---
+
+### 6.19 Goods Receipt Notes (GRN)
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/goods-receipts/{id}` | Get GRN by ID |
+| `GET` | `/api/purchase-orders/{id}/goods-receipts` | List GRNs for a PO |
+| `POST` | `/api/purchase-orders/{id}/goods-receipts` | Create GRN for PO |
+
+#### Create GRN — `POST /api/purchase-orders/{id}/goods-receipts`
+Returns `409 Conflict` if the PO is not in a receivable status.
+```json
+{
+ "warehouseId": "guid",
+ "items": [
+ {
+ "purchaseOrderItemId": "guid", // PO line being received
+ "acceptedQuantity": 98,
+ "rejectedQuantity": 2
+ }
+ ]
+}
+```
+
+#### GRN Response
+```json
+{
+ "id": "guid",
+ "purchaseOrderId": "guid",
+ "receivedByUserId": "guid",
+ "warehouseId": "guid",
+ "warehouseName": "string",
+ "receivedAt": "datetime",
+ "createdAt": "datetime",
+ "items": [
+ {
+ "id": "guid",
+ "purchaseOrderItemId": "guid",
+ "itemId": "guid",
+ "sku": "string",
+ "itemName": "string",
+ "acceptedQuantity": 98,
+ "rejectedQuantity": 2
+ }
+ ]
+}
+```
+
+---
+
+### 6.20 Purchase Returns
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/purchase-returns/{id}` | Get purchase return by ID |
+| `GET` | `/api/purchase-orders/{id}/returns` | List returns for a PO |
+| `POST` | `/api/purchase-orders/{id}/returns` | Create purchase return for PO |
+
+#### Create Purchase Return — `POST /api/purchase-orders/{id}/returns`
+Returns `409 Conflict` on business rule violations.
+```json
+{
+ "reason": "string",
+ "items": [
+ {
+ "itemId": "guid",
+ "quantity": 5
+ }
+ ]
+}
+```
+
+#### Purchase Return Response
+```json
+{
+ "id": "guid",
+ "purchaseOrderId": "guid",
+ "reason": "string",
+ "isActive": true,
+ "createdAt": "datetime",
+ "items": [
+ {
+ "id": "guid",
+ "itemId": "guid",
+ "sku": "string",
+ "itemName": "string",
+ "quantity": 5
+ }
+ ]
+}
+```
+
+---
+
+### 6.21 Stock
+
+**Base path:** `/api/stock`
+**Required:** JWT token (no specific permission policy)
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/stock/balances` | Get stock balances (paginated, filterable) |
+| `GET` | `/api/stock/balances/{itemId}/total` | Get total stock across all locations for item |
+| `GET` | `/api/stock/movements` | Get stock movement history (paginated, filterable) |
+| `POST` | `/api/stock/transfer` | Transfer stock between locations |
+| `POST` | `/api/stock/damage` | Record damaged stock write-off |
+| `POST` | `/api/stock/customer-return` | Record customer return to vehicle |
+| `POST` | `/api/stock/adjustment` | Record stock adjustment |
+
+#### Get Stock Balances — `GET /api/stock/balances`
+Query params (all optional):
+| Param | Type | Description |
+|---|---|---|
+| `locationId` | `guid` | Filter by specific location (warehouse or vehicle ID) |
+| `locationType` | `LocationType` | Filter by location type (`Warehouse` or `Vehicle`) |
+| `itemId` | `guid` | Filter by item |
+| `page` | `int` | Default: 1 |
+| `pageSize` | `int` | Default: 20 |
+
+**Response:** Paginated list of `StockBalanceResponse`:
+```json
+{
+ "page": 1, "pageSize": 20, "total": 100,
+ "items": [
+ {
+ "itemId": "guid",
+ "itemName": "string",
+ "locationId": "guid",
+ "locationType": "Warehouse",
+ "quantity": 250.0,
+ "updatedAt": "datetime"
+ }
+ ]
+}
+```
+
+#### Get Item Total — `GET /api/stock/balances/{itemId}/total`
+```json
+{
+ "itemId": "guid",
+ "itemName": "string",
+ "totalQuantity": 500.0,
+ "breakdown": [
+ {
+ "itemId": "guid",
+ "itemName": "string",
+ "locationId": "guid",
+ "locationType": "Vehicle",
+ "quantity": 250.0,
+ "updatedAt": "datetime"
+ }
+ ]
+}
+```
+
+#### Get Stock Movements — `GET /api/stock/movements`
+Query params (all optional):
+| Param | Type | Description |
+|---|---|---|
+| `locationId` | `guid` | Filter by location |
+| `itemId` | `guid` | Filter by item |
+| `type` | `MovementType` | Filter by movement type |
+| `from` | `datetime` | Start date filter |
+| `to` | `datetime` | End date filter |
+| `page` | `int` | Default: 1 |
+| `pageSize` | `int` | Default: 20 |
+
+**Response:** Paginated list of `StockMovementResponse`:
+```json
+{
+ "items": [
+ {
+ "id": "guid",
+ "itemId": "guid",
+ "itemName": "string",
+ "fromLocationId": "guid | null",
+ "fromLocationType": "Warehouse | null",
+ "toLocationId": "guid | null",
+ "toLocationType": "Vehicle | null",
+ "quantity": 100.0,
+ "type": "Transfer",
+ "reasonCode": "string | null",
+ "referenceId": "guid | null", // e.g. SaleId, RideId, etc.
+ "createdAt": "datetime",
+ "createdBy": "guid"
+ }
+ ]
+}
+```
+
+#### Transfer Stock — `POST /api/stock/transfer`
+```json
+{
+ "itemId": "guid",
+ "fromLocationId": "guid",
+ "fromLocationType": "Warehouse",
+ "toLocationId": "guid",
+ "toLocationType": "Vehicle",
+ "quantity": 50.0,
+ "referenceId": "guid | null" // e.g. loading sheet ID
+}
+```
+
+#### Record Damage — `POST /api/stock/damage`
+```json
+{
+ "itemId": "guid",
+ "locationId": "guid",
+ "locationType": "Warehouse",
+ "quantity": 3.0,
+ "reasonCode": "string" // e.g. "WaterDamage", "ExpiredGoods"
+}
+```
+
+#### Record Customer Return — `POST /api/stock/customer-return`
+```json
+{
+ "itemId": "guid",
+ "vehicleId": "guid", // returns go to vehicle stock
+ "quantity": 2.0,
+ "referenceId": "guid | null"
+}
+```
+
+#### Record Adjustment — `POST /api/stock/adjustment`
+```json
+{
+ "itemId": "guid",
+ "locationId": "guid",
+ "locationType": "Warehouse",
+ "quantity": -5.0, // negative = reduce stock, positive = add stock
+ "reasonCode": "string"
+}
+```
+
+---
+
+### 6.22 Driver Profiles
+
+**Base path:** `/api/driver-profiles`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/driver-profiles` | `driver_profiles.view` | List driver profiles (paginated) |
+| `GET` | `/api/driver-profiles/{id}` | `driver_profiles.view` | Get driver profile by ID |
+| `POST` | `/api/driver-profiles` | `driver_profiles.create` | Create driver profile |
+| `PUT` | `/api/driver-profiles/{id}` | `driver_profiles.update` | Update driver profile |
+| `DELETE` | `/api/driver-profiles/{id}` | `driver_profiles.deactivate` | Deactivate driver profile |
+
+#### List — `GET /api/driver-profiles?page=1&pageSize=20`
+
+#### Create Driver Profile — `POST /api/driver-profiles`
+```json
+{
+ "fK_UserId": "guid", // user to link this profile to
+ "licenseNumber": "string",
+ "licenseExpiry": "datetime",
+ "medicalClearanceExpiry": "datetime"
+}
+```
+
+#### Update Driver Profile — `PUT /api/driver-profiles/{id}`
+```json
+{
+ "licenseNumber": "string",
+ "licenseExpiry": "datetime",
+ "medicalClearanceExpiry": "datetime"
+}
+```
+
+#### Driver Profile Response
+```json
+{
+ "id": "guid",
+ "userId": "guid",
+ "userFullName": "string",
+ "licenseNumber": "string",
+ "licenseExpiry": "datetime",
+ "medicalClearanceExpiry": "datetime",
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.23 Driver Compliance Config
+
+**Base path:** `/api/driver-compliance-config`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/driver-compliance-config` | `compliance.manage` | Get current config |
+| `PUT` | `/api/driver-compliance-config` | `compliance.manage` | Create or update config |
+
+This is a singleton resource — only one config record exists system-wide.
+
+**GET response** (returns `204 No Content` if not yet configured):
+```json
+{
+ "id": "guid",
+ "enforceLicenseExpiry": true,
+ "enforceMedicalClearance": true,
+ "updatedAt": "datetime",
+ "updatedBy": "guid"
+}
+```
+
+**PUT request:**
+```json
+{
+ "enforceLicenseExpiry": true,
+ "enforceMedicalClearance": false
+}
+```
+
+---
+
+### 6.24 Rides
+
+**Base path:** `/api/rides`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/rides` | `rides.view` | List rides (paginated, filterable) |
+| `GET` | `/api/rides/{id}` | `rides.view` | Get ride detail |
+| `POST` | `/api/rides` | `rides.create` | Create ride |
+| `PUT` | `/api/rides/{id}/driver` | `rides.manage` | Assign/change driver |
+| `POST` | `/api/rides/{id}/open` | `rides.manage` | Transition to Open status |
+| `POST` | `/api/rides/{id}/activate` | `rides.manage` | Activate ride (record meter start) |
+| `POST` | `/api/rides/{id}/close` | `rides.manage` | Close ride (record meter end) |
+| `POST` | `/api/rides/{id}/cancel-request` | `rides.cancel.request` | Raise a cancel request |
+| `GET` | `/api/rides/cancel-requests` | `rides.view` | List all cancel requests |
+| `POST` | `/api/rides/cancel-requests/{id}/approve` | `rides.cancel.approve` | Approve cancel request |
+| `POST` | `/api/rides/cancel-requests/{id}/reject` | `rides.cancel.approve` | Reject cancel request |
+| `GET` | `/api/rides/{id}/cancel-request` | `rides.view` | Get cancel request for a ride |
+| `GET` | `/api/rides/breakdown-assist/{rideId}` | `rides.view` | Get breakdown assist data |
+
+#### Ride Lifecycle
+
+```
+Draft → Open → Active → Closed
+ ↓
+ CancelRequested → Cancelled
+```
+
+#### List Rides — `GET /api/rides`
+Query params (all optional):
+| Param | Type | Description |
+|---|---|---|
+| `status` | `RideStatus` | Filter by status |
+| `type` | `RideType` | Filter by ride type |
+| `driverProfileId` | `guid` | Filter by driver |
+| `vehicleId` | `guid` | Filter by vehicle |
+| `page` | `int` | Default: 1 |
+| `pageSize` | `int` | Default: 20 |
+
+#### Create Ride — `POST /api/rides`
+```json
+{
+ "rideType": "Delivery",
+ "fK_VehicleId": "guid",
+ "fK_DriverProfileId": "guid",
+ "fK_RouteId": "guid",
+ "fK_SourceWarehouseId": "guid | null"
+}
+```
+
+#### Assign Driver — `PUT /api/rides/{id}/driver`
+```json
+{ "fK_DriverProfileId": "guid" }
+```
+
+#### Open Ride — `POST /api/rides/{id}/open`
+No body required.
+
+#### Activate Ride — `POST /api/rides/{id}/activate`
+```json
+{ "meterstart": 12345 } // odometer reading at start (integer)
+```
+
+#### Close Ride — `POST /api/rides/{id}/close`
+```json
+{ "meterend": 12567 } // odometer reading at end (integer)
+```
+
+#### Raise Cancel Request — `POST /api/rides/{id}/cancel-request`
+```json
+{
+ "typeCode": "VehicleBreakdown", // CancelRequestType enum
+ "reason": "string"
+}
+```
+
+#### List Cancel Requests — `GET /api/rides/cancel-requests`
+Query params: `?status={CancelRequestStatus}&page=1&pageSize=20`
+
+#### Ride Response
+```json
+{
+ "id": "guid",
+ "rideType": "Delivery",
+ "status": "Active",
+ "vehicleId": "guid",
+ "vehicleNumberPlate": "ABC-1234",
+ "driverProfileId": "guid",
+ "driverFullName": "string",
+ "routeId": "guid",
+ "routeName": "string",
+ "sourceWarehouseId": "guid",
+ "sourceWarehouseName": "string",
+ "meterstart": 12345,
+ "meterend": 0,
+ "startedAt": "datetime | null",
+ "activatedAt": "datetime | null",
+ "closedAt": "datetime | null",
+ "createdAt": "datetime"
+}
+```
+
+#### Cancel Request Response
+```json
+{
+ "id": "guid",
+ "rideId": "guid",
+ "typeCode": "VehicleBreakdown",
+ "reason": "string",
+ "requestedBy": "guid",
+ "requestedAt": "datetime",
+ "status": "Pending",
+ "approvedBy": "guid | null",
+ "approvedAt": "datetime | null"
+}
+```
+
+#### Breakdown Assist Response — `GET /api/rides/breakdown-assist/{rideId}`
+Returns active rides on the same route with stock levels, and the open orders for the broken-down ride.
+```json
+{
+ "rideId": "guid",
+ "activeRides": [
+ {
+ "rideId": "guid",
+ "vehicleId": "guid",
+ "vehicleNumberPlate": "string",
+ "driverProfileId": "guid",
+ "driverFullName": "string",
+ "routeId": "guid",
+ "routeName": "string",
+ "stockLevels": [
+ { "itemId": "guid", "itemName": "string", "locationId": "guid", "locationType": "Vehicle", "quantity": 50.0, "updatedAt": "datetime" }
+ ]
+ }
+ ],
+ "openOrders": [
+ {
+ "orderId": "guid",
+ "storeId": "guid",
+ "storeName": "string",
+ "orderDate": "datetime",
+ "totalAmount": 25000.00
+ }
+ ]
+}
+```
+
+---
+
+### 6.25 Loading Sheets
+
+**Base path:** `/api/loading-sheets`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/loading-sheets` | `loading.view` | List loading sheets (paginated, filterable) |
+| `GET` | `/api/loading-sheets/{id}` | `loading.view` | Get loading sheet detail |
+| `POST` | `/api/loading-sheets` | `loading.dispatch` | Create loading sheet for a ride |
+| `PUT` | `/api/loading-sheets/{id}/items` | `loading.dispatch` | Set dispatched items |
+| `POST` | `/api/loading-sheets/{id}/confirm-dispatch` | `loading.dispatch` | Confirm dispatch from warehouse |
+| `PUT` | `/api/loading-sheets/{id}/receipt-items` | `loading.receive` | Set received item quantities |
+| `POST` | `/api/loading-sheets/{id}/confirm-receipt` | `loading.receive` | Driver confirms receipt |
+| `POST` | `/api/loading-sheets/{id}/resolve` | `loading.resolve` | Resolve quantity discrepancy |
+
+#### Loading Sheet Lifecycle
+
+```
+Pending → DispatchConfirmed → ReceiptConfirmed
+ ↓ (if quantities differ)
+ DiscrepancyFlagged → Resolved
+```
+
+#### List Loading Sheets — `GET /api/loading-sheets`
+Query params (all optional):
+| Param | Type | Description |
+|---|---|---|
+| `rideId` | `guid` | Filter by ride |
+| `status` | `LoadingSheetStatus` | Filter by status |
+| `page` | `int` | Default: 1 |
+| `pageSize` | `int` | Default: 20 |
+
+#### Create Loading Sheet — `POST /api/loading-sheets`
+```json
+{ "fK_RideId": "guid" }
+```
+
+#### Set Dispatched Items — `PUT /api/loading-sheets/{id}/items`
+Replaces all items on the loading sheet.
+```json
+{
+ "items": [
+ { "fK_ItemId": "guid", "quantity": 100.0 }
+ ]
+}
+```
+
+#### Confirm Dispatch — `POST /api/loading-sheets/{id}/confirm-dispatch`
+No body required. Transitions status to `DispatchConfirmed`.
+
+#### Set Receipt Items — `PUT /api/loading-sheets/{id}/receipt-items`
+```json
+{
+ "items": [
+ { "fK_ItemId": "guid", "quantityReceived": 98.0 }
+ ]
+}
+```
+
+#### Confirm Receipt — `POST /api/loading-sheets/{id}/confirm-receipt`
+```json
+{ "meterstart": 12345 } // driver's vehicle odometer at receipt
+```
+
+#### Resolve Discrepancy — `POST /api/loading-sheets/{id}/resolve`
+```json
+{
+ "items": [
+ { "fK_ItemId": "guid", "quantityResolved": 98.0 }
+ ],
+ "meterstart": 12345
+}
+```
+
+#### Loading Sheet Response
+```json
+{
+ "id": "guid",
+ "fK_RideId": "guid",
+ "fK_SourceWarehouseId": "guid",
+ "warehouseName": "string",
+ "status": "Pending",
+ "dispatchConfirmedAt": "datetime | null",
+ "dispatchConfirmedBy": "guid | null",
+ "receiptConfirmedAt": "datetime | null",
+ "receiptConfirmedBy": "guid | null",
+ "createdAt": "datetime",
+ "items": [
+ {
+ "id": "guid",
+ "fK_ItemId": "guid",
+ "itemName": "string",
+ "quantityDispatched": 100.0,
+ "quantityReceived": 98.0,
+ "quantityResolved": 98.0
+ }
+ ]
+}
+```
+
+---
+
+### 6.26 Delivery Tasks
+
+**Base path:** `/api` (mixed routes — see below)
+
+| Method | Path | Description |
+|---|---|---|
+| `POST` | `/api/rides/{rideId}/tasks/generate` | Auto-generate delivery tasks from ride's orders |
+| `GET` | `/api/rides/{rideId}/tasks` | List all tasks for a ride |
+| `GET` | `/api/delivery-tasks/{id}` | Get delivery task by ID |
+| `PUT` | `/api/delivery-tasks/{id}/quantities` | Set fulfilled quantities per item |
+| `POST` | `/api/delivery-tasks/{id}/confirm` | Mark task as delivered |
+| `POST` | `/api/delivery-tasks/{id}/fail` | Mark task as failed |
+
+#### Generate Tasks — `POST /api/rides/{rideId}/tasks/generate`
+No body. Generates one `DeliveryTask` per store/order on the ride.
+Returns `400 Bad Request` if ride is not in a valid state, `404 Not Found` if ride not found.
+
+**Response:** Array of `DeliveryTaskResponse`.
+
+#### Set Quantities — `PUT /api/delivery-tasks/{id}/quantities`
+```json
+{
+ "items": [
+ { "itemId": "guid", "quantityFulfilled": 8 }
+ ]
+}
+```
+
+#### Fail Task — `POST /api/delivery-tasks/{id}/fail`
+```json
+{ "note": "string" } // reason for failure
+```
+
+#### Delivery Task Response
+```json
+{
+ "id": "guid",
+ "fK_RideId": "guid",
+ "fK_OrderId": "guid",
+ "storeId": "guid",
+ "storeName": "string",
+ "sequence": 1,
+ "status": "Pending",
+ "customerConfirmed": false,
+ "confirmedAt": "datetime | null",
+ "note": "string | null",
+ "items": [
+ {
+ "itemId": "guid",
+ "itemName": "string",
+ "quantity": 10,
+ "quantityFulfilled": 8,
+ "unitPrice": 150.00
+ }
+ ],
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.27 Sales
+
+**Base path:** `/api/sales`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/sales` | `orders.view` | List all sales |
+| `GET` | `/api/sales/{id}` | `orders.view` | Get sale detail |
+| `GET` | `/api/sales/store/{storeId}` | `orders.view` | Get sales for a store (scope-enforced) |
+| `POST` | `/api/sales` | `orders.create` | Create a new sale |
+
+> `GET /api/sales/store/{storeId}` returns `403 Forbidden` if current user's scope does not include that store.
+
+#### Create Sale — `POST /api/sales`
+```json
+{
+ "storeId": "guid",
+ "saleDate": "datetime",
+ "totalAmount": 45000.00,
+ "fulfillImmediately": false, // if true, immediately fulfills the linked order
+ "rideId": "guid | null", // associate with a ride (for mobile sales)
+ "items": [
+ {
+ "itemId": "guid",
+ "quantity": 10,
+ "unitPrice": null // null = use price list; decimal = override price
+ }
+ ]
+}
+```
+
+**Returns `403 Forbidden`** if current user's scope doesn't include the store.
+
+#### Sale Response
+```json
+{
+ "id": "guid",
+ "saleDate": "datetime",
+ "storeId": "guid",
+ "createdByUserId": "guid",
+ "rideId": "guid | null",
+ "totalAmount": 45000.00,
+ "status": "Pending",
+ "paymentStatus": "Pending",
+ "fulfillImmediately": false,
+ "completedAt": "datetime | null",
+ "orderId": "guid", // the linked Order record
+ "orderStatus": "Pending",
+ "orderFulfilledAt": "datetime | null",
+ "items": [
+ {
+ "itemId": "guid",
+ "quantity": 10,
+ "quantityFulfilled": 0,
+ "unitPrice": 150.00
+ }
+ ],
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.28 Orders
+
+**Base path:** `/api/orders`
+
+| Method | Path | Permission | Description |
+|---|---|---|---|
+| `GET` | `/api/orders` | `orders.view` | List all orders |
+| `GET` | `/api/orders/{id}` | `orders.view` | Get order by ID |
+| `GET` | `/api/orders/store/{storeId}` | `orders.view` | Get orders for a store (scope-enforced) |
+| `POST` | `/api/orders` | `orders.create` | Create order (linked to a sale) |
+| `PUT` | `/api/orders/{id}` | `orders.update` | Update order status |
+| `DELETE` | `/api/orders/{id}` | `orders.delete` | Delete order |
+| `POST` | `/api/orders/{id}/apply-scheme` | — | Apply a scheme to order |
+
+> `GET /api/orders/store/{storeId}` and `POST /api/orders` return `403 Forbidden` if scope is not satisfied.
+
+#### Create Order — `POST /api/orders`
+```json
+{ "saleId": "guid" }
+```
+
+#### Update Order — `PUT /api/orders/{id}`
+```json
+{ "status": "Fulfilled" } // OrderStatus enum
+```
+
+#### Order Response
+```json
+{
+ "id": "guid",
+ "saleId": "guid",
+ "orderDate": "datetime",
+ "storeId": "guid",
+ "totalAmount": 45000.00,
+ "status": "Pending",
+ "fulfilledAt": "datetime | null",
+ "items": [
+ {
+ "itemId": "guid",
+ "quantity": 10,
+ "quantityFulfilled": 0,
+ "unitPrice": 150.00
+ }
+ ],
+ "isActive": true,
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.29 Payments
+
+**Base path:** `/api/sales/{saleId}/payments`
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/api/sales/{saleId}/payments` | Get all payments for a sale |
+| `POST` | `/api/sales/{saleId}/payments` | Record a payment for a sale |
+
+> `GET` returns `404 Not Found` if the sale doesn't exist.
+> `POST` returns `404 Not Found` if the sale doesn't exist, `400 Bad Request` if payment already complete.
+
+#### Create Payment — `POST /api/sales/{saleId}/payments`
+```json
+{
+ "amount": 25000.00,
+ "method": "Cash" // PaymentMethod enum
+}
+```
+
+#### Payment Response
+```json
+{
+ "id": "guid",
+ "saleId": "guid",
+ "storeId": "guid",
+ "amount": 25000.00,
+ "method": "Cash",
+ "collectedAt": "datetime",
+ "collectedBy": "guid", // user who recorded the payment
+ "createdAt": "datetime"
+}
+```
+
+---
+
+### 6.30 Sync (ERP Import)
+
+**Base path:** `/api/sync`
+**Required permission:** `items.create`
+
+These endpoints are for bulk import from an external ERP system. Each payload is idempotent — existing records (matched by `erpId`) are updated; new records are inserted.
+
+| Method | Path | Description |
+|---|---|---|
+| `POST` | `/api/sync/uoms` | Bulk upsert units of measure |
+| `POST` | `/api/sync/categories` | Bulk upsert categories + subcategories |
+| `POST` | `/api/sync/brands` | Bulk upsert brands |
+| `POST` | `/api/sync/items` | Bulk upsert items |
+
+#### Sync UOMs — `POST /api/sync/uoms`
+```json
+{
+ "items": [
+ { "erpId": 101, "name": "Piece" }
+ ]
+}
+```
+
+#### Sync Categories — `POST /api/sync/categories`
+```json
+{
+ "items": [
+ {
+ "erpId": 10,
+ "name": "Beverages",
+ "subCategories": [
+ { "erpId": 101, "name": "Soft Drinks", "parentErpId": 10 }
+ ]
+ }
+ ]
+}
+```
+
+#### Sync Brands — `POST /api/sync/brands`
+```json
+{
+ "items": [
+ { "erpId": 201, "name": "Coca-Cola" }
+ ]
+}
+```
+
+#### Sync Items — `POST /api/sync/items`
+```json
+{
+ "items": [
+ {
+ "erpId": 5001,
+ "sku": "CC-330ML",
+ "name": "Coca-Cola 330ml",
+ "description": "string | null",
+ "categoryErpId": 10,
+ "subCategoryErpId": 101,
+ "brandErpId": 201,
+ "baseUomErpId": 101,
+ "salePrice": 65.00,
+ "isActive": true
+ }
+ ]
+}
+```
+
+#### Sync Result Response (all sync endpoints)
+```json
+{
+ "inserted": 5,
+ "updated": 12,
+ "skipped": 0
+}
+```
+
+---
+
+## 7. Business Flows
+
+End-to-end sequences showing which API calls to make, in order, for each major workflow. Every step maps directly to an endpoint documented in Section 6.
+
+---
+
+### 7.1 Region → Route → Store Setup
+
+This is the master data foundation. Everything else (rides, sales, delivery) depends on it existing first.
+
+```
+1. POST /api/regions → creates a region (geographic area)
+2. POST /api/routes → creates a route inside that region
+ (requires regionId)
+3. POST /api/stores → creates a store on that route
+ (requires routeId)
+4. POST /api/stores/{id}/kyc-documents → upload KYC docs for the store
+5. PUT /api/stores/{id}/kyc/verify → approve/reject KYC
+ (kycStatus: Verified unlocks full ordering)
+```
+
+**Key rules:**
+- A store belongs to exactly one route; a route belongs to exactly one region.
+- Stores with `KycStatus = Pending` can only place orders up to the configured threshold (default 0 — meaning blocked). Verify KYC before the store can transact.
+- Users are scoped to regions/routes/stores via `PUT /api/users/{id}/scope`. Scoped users only see and operate on data within their assigned scope.
+
+---
+
+### 7.2 Sale Flow (Sales Run)
+
+Used when a sales rep visits a store and records a sale on the spot. The sale and its linked order are created atomically.
+
+```
+1. POST /api/sales
+ body: { storeId, saleDate, totalAmount, fulfillImmediately, rideId?, items[] }
+ → creates Sale + Order in one call
+ → Sale.status = Pending, Order.status = Pending (if fulfillImmediately: false)
+ → Sale.status = Completed, Order.status = Fulfilled (if fulfillImmediately: true)
+
+2. (Optional) GET /api/schemes/eligible?storeId=...&orderId=...
+ → find promotions the store qualifies for
+
+3. (Optional) POST /api/orders/{orderId}/apply-scheme
+ body: { schemeId }
+ → applies discount or free-goods scheme to the order
+
+4. POST /api/sales/{saleId}/payments
+ body: { amount, method: "Cash"|"Credit" }
+ → records a payment; Sale.paymentStatus updates automatically:
+ Pending → PartiallyPaid → Paid (based on cumulative total vs totalAmount)
+ → repeat this step for partial payments
+```
+
+**Price resolution (automatic on sale creation):**
+- If `items[].unitPrice` is `null`, the system looks up the active price list for the store's region and uses that price.
+- If `unitPrice` is supplied, it must fall within `[minPrice, price]` from the price list. Prices outside this range cause the sale to be rejected (`null` response → treat as validation error).
+
+**Stock deduction (when `fulfillImmediately: true` + `rideId` supplied):**
+- The system checks that the vehicle has sufficient stock for every item.
+- Stock is deducted from the vehicle immediately and a `Sale` movement is recorded.
+- If any item lacks stock, the entire sale creation fails.
+
+**Scope enforcement:**
+- The creating user must have scope access to the store, otherwise `403 Forbidden` is returned.
+
+---
+
+### 7.3 Delivery Flow (End-to-End Ride Lifecycle)
+
+The full sequence for a delivery run — from creating the ride to closing it out.
+
+#### Step 1 — Set up the ride (Draft)
+```
+POST /api/rides
+body: { rideType: "Delivery", fK_VehicleId, fK_DriverProfileId, fK_RouteId, fK_SourceWarehouseId? }
+→ Ride.status = Draft
+
+PUT /api/rides/{id}/driver ← optional, change driver while still Draft
+body: { fK_DriverProfileId }
+```
+
+#### Step 2 — Open the ride (Draft → Open)
+```
+POST /api/rides/{id}/open
+→ Ride.status = Open
+```
+
+**Blocked if:**
+- The vehicle already has another Open or Active ride.
+- Driver compliance is enforced and the driver's license or medical clearance has expired.
+
+#### Step 3 — Create & confirm the loading sheet (Open ride required)
+```
+POST /api/loading-sheets
+body: { fK_RideId }
+→ LoadingSheet.status = Pending
+ (For Delivery rides: items are auto-populated from pending orders on the route.
+ For Sale rides: items must be added manually in the next step.)
+
+PUT /api/loading-sheets/{id}/items ← set/adjust dispatched quantities
+body: { items: [{ fK_ItemId, quantity }] }
+
+POST /api/loading-sheets/{id}/confirm-dispatch
+→ LoadingSheet.status = DispatchConfirmed
+ (Warehouse side confirms the goods left the warehouse)
+```
+
+#### Step 4 — Driver receives stock & ride goes Active
+```
+PUT /api/loading-sheets/{id}/receipt-items ← driver records what they actually received
+body: { items: [{ fK_ItemId, quantityReceived }] }
+
+POST /api/loading-sheets/{id}/confirm-receipt
+body: { meterstart: 12345 }
+→ if quantities match dispatched:
+ LoadingSheet.status = ReceiptConfirmed
+ Stock transfers from Warehouse → Vehicle (Load movement recorded)
+ Ride.status = Active, Ride.meterstart set
+
+→ if quantities differ:
+ LoadingSheet.status = DiscrepancyFlagged
+ (Ride stays Open; must resolve before activation)
+
+POST /api/loading-sheets/{id}/resolve ← if DiscrepancyFlagged
+body: { items: [{ fK_ItemId, quantityResolved }], meterstart }
+→ LoadingSheet.status = Resolved
+ Stock transfers using resolved quantities
+ Ride.status = Active
+```
+
+#### Step 5 — Generate delivery tasks & execute deliveries (Active ride, Delivery type only)
+```
+POST /api/rides/{rideId}/tasks/generate
+→ creates one DeliveryTask per pending order on the route
+ (idempotent — skips orders that already have a task for this ride)
+
+GET /api/rides/{rideId}/tasks ← list tasks to display the delivery sequence
+
+For each task:
+ PUT /api/delivery-tasks/{id}/quantities
+ body: { items: [{ itemId, quantityFulfilled }] }
+ → set how much was actually delivered per item
+
+ POST /api/delivery-tasks/{id}/confirm
+ → DeliveryTask.status = Delivered (all qty) or PartiallyDelivered
+ Order.status = Fulfilled or PartiallyFulfilled
+ Sale.status = Completed (if fully delivered)
+ Stock deducted from Vehicle (Sale movement recorded)
+
+ — OR —
+
+ POST /api/delivery-tasks/{id}/fail
+ body: { note: "reason" }
+ → DeliveryTask.status = Failed (stock not deducted)
+```
+
+#### Step 6 — Close the ride (Active → Closed)
+```
+POST /api/rides/{id}/close
+body: { meterend: 12567 }
+→ Ride.status = Closed
+```
+
+**Blocked if:** The vehicle still has stock with quantity > 0. All stock must be returned (via transfer, damage, or customer-return endpoints) before closing.
+
+---
+
+### 7.4 Ride Cancellation Flow
+
+Applies when a ride is Active and something goes wrong (breakdown, driver issue, etc.).
+
+```
+POST /api/rides/{id}/cancel-request
+body: { typeCode: "VehicleBreakdown", reason: "string" }
+→ Ride.status = CancelRequested
+ RideCancelRequest.status = Pending
+
+GET /api/rides/cancel-requests?status=Pending ← supervisor polls for pending requests
+GET /api/rides/{id}/cancel-request ← check status for a specific ride
+
+POST /api/rides/cancel-requests/{requestId}/approve
+→ RideCancelRequest.status = Approved
+ Ride.status = Cancelled
+ (Blocked if vehicle still has stock)
+
+— OR —
+
+POST /api/rides/cancel-requests/{requestId}/reject
+→ RideCancelRequest.status = Rejected
+ Ride.status = Active (restored)
+```
+
+**Breakdown Assist** — when a ride is cancelled mid-route, use this to find a replacement:
+```
+GET /api/rides/breakdown-assist/{cancelledRideId}
+→ returns: other Active rides on the same route with their current vehicle stock levels
+ + open orders from the cancelled ride's route
+```
+
+---
+
+### 7.5 Payment Flow
+
+Payments are recorded against a Sale after it is created. Multiple partial payments are supported.
+
+```
+1. POST /api/sales ← sale created; paymentStatus = Pending
+
+2. POST /api/sales/{saleId}/payments
+ body: { amount: 25000.00, method: "Cash" }
+ → payment recorded; sale.paymentStatus updated:
+ sum(payments) == 0 → Pending
+ sum(payments) < totalAmount → PartiallyPaid
+ sum(payments) >= totalAmount → Paid
+
+3. Repeat step 2 for additional instalments.
+
+4. GET /api/sales/{saleId}/payments ← list all payments for the sale
+```
+
+**Rules:**
+- Cannot record payment against a `Cancelled` sale — returns `400 Bad Request`.
+- No upper limit enforced — overpayment is allowed (handle reconciliation in the UI).
+- `collectedBy` is automatically set from the authenticated user's ID.
+
+---
+
+### 7.6 Purchase Order Flow (Procurement)
+
+```
+1. POST /api/purchase-orders
+ body: { supplierId, items: [{ itemId, quantity, unitCost }] }
+ → PO.status = Issued
+
+2. (Optional) PUT /api/purchase-orders/{id} ← edit lines while Issued only
+ body: { items: [{ purchaseOrderItemId?, itemId, quantity, unitCost }] }
+ (purchaseOrderItemId null = add new line, guid = update existing)
+
+3. POST /api/purchase-orders/{id}/goods-receipts ← goods arrive at warehouse
+ body: { warehouseId, items: [{ purchaseOrderItemId, acceptedQuantity, rejectedQuantity }] }
+ → GRN created; stock added to Warehouse (PurchaseReceipt movement)
+ → PO.status = PartiallyReceived or Received (if all lines fully received)
+
+4. POST /api/purchase-orders/{id}/match-invoice ← optional 3-way match
+ query: ?grnId=...&invoiceId=...
+ → returns line-by-line comparison of ordered vs received quantities
+
+5. (if discrepancy) POST /api/purchase-orders/{id}/resolve-variance
+ body: { variance, resolution, adjustedUnitCost?, purchaseOrderItemId? }
+
+6. POST /api/purchase-orders/{id}/close ← manually close when done
+ → PO.status = Closed
+
+ — OR —
+
+ POST /api/purchase-orders/{id}/cancel ← cancel entire PO (Issued only)
+ body: { reason }
+
+ POST /api/purchase-orders/{id}/items/{itemId}/cancel ← cancel one line (Issued only)
+ body: { reason? }
+```
+
+**Returns:** `GET /api/purchase-orders/{id}/returns` / `POST /api/purchase-orders/{id}/returns`
+Record supplier return: `{ reason, items: [{ itemId, quantity }] }` → stock deducted from Warehouse (PurchaseReturn movement).
+
+---
+
+### 7.7 Loading Sheet Flow (Detail)
+
+The loading sheet controls the physical movement of stock from warehouse to vehicle. It sits between "ride opened" and "ride active".
+
+```
+Status machine:
+Pending ──► DispatchConfirmed ──► ReceiptConfirmed (clean)
+ └──► DiscrepancyFlagged ──► Resolved
+```
+
+| Status | Who acts | What happens to stock |
+|---|---|---|
+| `Pending` | Warehouse staff | No stock movement yet; edit items freely |
+| `DispatchConfirmed` | Warehouse staff | Goods physically leave warehouse; no stock change yet |
+| `ReceiptConfirmed` | Driver | Stock transferred Warehouse → Vehicle; ride goes Active |
+| `DiscrepancyFlagged` | System | Quantities differ; ride stays Open; waiting for resolution |
+| `Resolved` | Supervisor | Resolved quantities transferred; ride goes Active |
+
+**Important:** confirming receipt (or resolving discrepancy) is what actually activates the ride and loads stock onto the vehicle. The `meterstart` odometer reading is recorded at this point.
+
+---
+
+### 7.8 Stock Flow (How Stock Moves)
+
+Stock is tracked per item per location (Warehouse or Vehicle). Every write operation creates an immutable `StockMovement` record.
+
+| Trigger | API call | Movement type | Direction |
+|---|---|---|---|
+| GRN received | `POST /api/purchase-orders/{id}/goods-receipts` | `PurchaseReceipt` | → Warehouse |
+| Loading sheet confirmed | `POST /api/loading-sheets/{id}/confirm-receipt` | `Load` | Warehouse → Vehicle |
+| Sale fulfilled (immediate) | `POST /api/sales` (fulfillImmediately: true) | `Sale` | Vehicle → (out) |
+| Delivery task confirmed | `POST /api/delivery-tasks/{id}/confirm` | `Sale` | Vehicle → (out) |
+| Customer returns goods | `POST /api/stock/customer-return` | `CustomerReturn` | → Vehicle |
+| Supplier return | `POST /api/purchase-orders/{id}/returns` | `PurchaseReturn` | Warehouse → (out) |
+| Manual transfer | `POST /api/stock/transfer` | `Transfer` | Any → Any |
+| Damage write-off | `POST /api/stock/damage` | `Damage` | Location → (out) |
+| Manual correction | `POST /api/stock/adjustment` | `Adjustment` | Location ± delta |
+
+**Closing a ride requires zero vehicle stock.** Any remaining stock must be transferred back to a warehouse, recorded as damage, or returned by the customer before `POST /api/rides/{id}/close` will succeed.
+
+---
+
+### 7.9 Scheme / Promotion Flow
+
+```
+1. POST /api/schemes ← create a promotion
+ body: { name, ruleType, ruleConfig?, regionId?, eligibleSegment?,
+ dateRangeFrom, dateRangeTo, agentLimitExempt, baselineExpectedSales?,
+ ...optional free-goods fields }
+
+2. (if BuyXGetY or FreeBundle)
+ PUT /api/schemes/{id}/free-goods-rule
+ PUT /api/schemes/{id}/bundle-components/{itemId}
+
+3. At order time:
+ GET /api/schemes/eligible?storeId=...&orderId=...
+ → returns which schemes the store qualifies for, with discount/free-goods amounts
+
+4. POST /api/orders/{orderId}/apply-scheme
+ body: { schemeId }
+ → scheme applied to the order; SchemeRedemption recorded
+
+5. Analytics:
+ GET /api/schemes/{id}/spend ← total cost of scheme so far
+ GET /api/schemes/{id}/roi ← ROI vs baseline expected sales
+```
+
+**Rule types quick reference:**
+
+| Type | `ruleConfig` JSON | Trigger | Payout |
+|---|---|---|---|
+| `FlatPercent` | `{"discountPercent": 10.0}` | Any order | % off total |
+| `SlabDiscount` | `{"tiers":[{"minOrderAmount":10000,"discountPercent":5},...]}` | Order value reaches slab | % off total |
+| `BuyXGetY` | `null` | Buy N of trigger item | Free qty of free item |
+| `FreeBundle` | `null` | Buy all bundle components | Free qty of free item |
+
+---
+
+## Appendix A — Common Business Rules
+
+1. **Store KYC threshold:** A store with `KycStatus = Pending` has an order threshold of `0` (configurable via `StoreManagement:UnverifiedKycOrderThreshold`). Orders for unverified stores may be blocked or limited.
+
+2. **Credit enforcement:** On sale creation, the system checks `OutstandingBalance + saleAmount ≤ CreditLimit`. Exceeding this will block the sale.
+
+3. **Driver compliance:** When `EnforceLicenseExpiry = true`, a ride cannot be opened/activated if the assigned driver's license has expired. Same for `EnforceMedicalClearance`.
+
+4. **Ride state machine:** Transitions are strictly enforced — calling `open` on a non-Draft ride, `activate` on a non-Open ride, etc. returns `400 Bad Request`.
+
+5. **Loading sheet discrepancy:** When `ConfirmReceipt` is called and received quantities differ from dispatched quantities, the sheet automatically transitions to `DiscrepancyFlagged` instead of `ReceiptConfirmed`.
+
+6. **PO line cancellation:** Individual lines can be cancelled only while the PO is `Issued`. The entire PO can only be cancelled while `Issued`.
+
+7. **Scheme eligibility:** A scheme applies to a store only if the store's segment matches `EligibleSegment` (or the scheme has no segment restriction), the scheme is within its date range, and the region matches (or scheme has no region restriction).
+
+8. **Agent discount ceiling:** A role's `Level` field controls the maximum discount an agent can apply. Schemes with `AgentLimitExempt = true` stack on top regardless of this ceiling.
+
+9. **Scope enforcement:** Users with an empty scope list for a scope type (regions/routes/stores) are assumed to have **no restriction** for that type (system-wide access). Only explicitly listed IDs restrict access.
+
+---
+
+## Appendix B — Frontend Implementation Notes
+
+### Token / Session Strategy
+> ⚠️ **YET TO IMPLEMENT** — Auth mechanism not yet decided. Do not implement token storage until Section 1 is finalized.
+
+### Permission Gating
+Once auth is implemented, call `GET /api/roles/{roleId}/permissions` on login to pre-fetch all permissions for the session. Cache them client-side and use them to show/hide UI elements. The role ID will come from whatever credential the Auth Service issues.
+
+### Enum Serialization
+The API serializes enums as **strings** by default in ASP.NET Core `System.Text.Json` — verify with your actual deployment. If numbers are returned, map them using the tables in Section 4.
+
+### GUID Format
+Always send GUIDs as lowercase hyphenated strings: `"3fa85f64-5717-4562-b3fc-2c963f66afa6"`.
+
+### Date Format
+Send all dates as ISO 8601 strings. For date-only fields (license expiry), include a time component: `"2026-12-31T00:00:00Z"`.
+
+### Pagination
+For paginated endpoints, always implement `page` / `pageSize` query params in the UI. Default `pageSize` is `20`.
+
+### File Upload
+KYC document upload uses `multipart/form-data`. Do not set `Content-Type` manually when using browser `FormData` — the browser sets it with the boundary automatically.
+
+### Error Handling
+- `400` errors include `{ "error": "descriptive message" }` — surface this to the user.
+- `404` — show "not found" state.
+- `409` — show the conflict message from `{ "error": "..." }`.
+- `401` — redirect to login.
+- `403` — show "access denied" / hide the action.