# 14 — Pricing & Supplier Master Data **Status**: Done — migration applied to the database **Depends On**: 01 — Item Catalog (Done) --- ## Goal Covers the remainder of [mainPlan.md §7](./mainPlan.md#7-master-data-management) not already handled by [01_item_catalog.md](./01_item_catalog.md): price lists and supplier master records. `createProduct`/`deactivateSku` live in 01; this step is everything else in §7. --- ## Background / Context `Item.SalePrice` (from [01_item_catalog.md](./01_item_catalog.md)) is a single flat, optional price — `OrderItem.UnitPrice` is captured from it at order-creation time, or supplied manually if `SalePrice` is null. There is no concept of a region- or date-scoped price list, and no `Supplier` entity anywhere in the schema. `applyPricingAndDiscounts` ([mainPlan.md §1](./mainPlan.md#1-order-management-lifecycle)) is the function that resolves the actual price for an order line; this step implements the price-list half of it (region + date scoping). The discount/scheme half stays out of scope until [17_promotions_schemes.md](./17_promotions_schemes.md) lands — until then `applyPricingAndDiscounts` only walks price lists, never scheme discounts. Resolved in planning discussion (2026-08-20): price lists are **region-only in v1**, not segment-scoped. `Store.Segment` ([12_store_management.md](./12_store_management.md)) is not consulted by pricing. Segment-based pricing can be added as a later step without breaking this model, since it would only add an optional new scoping column. ### Negotiable price range (Min/Max) An item's price within a price list can float in a band instead of being fixed: the same actor who creates the price list (`pricing.create`/`pricing.update`, AreaManager and above) sets both a **ceiling** (`Price` — the list/default price) and an optional **floor** (`MinPrice`) below which no order for that item may be priced. The sales rep/driver placing the order can then supply any `UnitPrice` within `[MinPrice, Price]` on `OrderItemRequest` instead of always taking the resolved default — the same negotiation-boundary pattern already used for discount limits in [08_sales_agent_controls.md](./08_sales_agent_controls.md). - `PriceListItem.MinPrice (decimal?)` — `null` = no negotiation, the item is fixed at `Price`. - `PUT /api/price-lists/{id}/items/{sku}` accepts `{ "price": ..., "minPrice": ... }`; rejected with 409 if `MinPrice > Price`. - `OrderService.CreateAsync` validates any caller-supplied `OrderItemRequest.UnitPrice` against `[MinPrice ?? Price, Price]` for that item's resolved price (region-scoped list → global list; the `Item.SalePrice` fallback has no `MinPrice` and is always fixed). The order is rejected (400, via a null resolution) if the requested price falls outside the band, or if no price can be resolved at all. ### Retail price and agency margin (informational, not enforced on sales) Two more numbers can be tracked per `(PriceListItem)` beyond the wholesale `Price`/`MinPrice` band, covering the rest of the chain from distributor down to the end consumer: - **`RetailPrice` (decimal?)** — the MRP the store is expected to charge the end consumer. Not enforced anywhere (a store can charge whatever it wants at the till) — it exists so the wholesale `Price` can be validated against it (a store must always have a positive margin) and so the sales rep can see it while negotiating with a store. - **`AgencyMarginPercent` (decimal?)** — the percentage of `Price` the regional agency operating that territory retains as commission for running local distribution (sales force, vehicles, warehousing). Purely informational/reporting — not deducted from anything automatically; it feeds agency settlement/payout reporting, the same `RegionalAgency` role that raises Agency Claims ([18_returns_claims.md](./18_returns_claims.md)). Neither field participates in `ResolveUnitPriceAsync`'s pricing logic or the `[MinPrice, Price]` negotiation band — they ride along in the same `PriceListItem` row and `PriceResolution` response purely for display and reporting. - `PriceListItem.RetailPrice (decimal?)` — must be `>= Price` when set (409 if violated); `null` = not tracked for this item/list. - `PriceListItem.AgencyMarginPercent (decimal?)` — must be between 0 and 100 when set (409 if violated); `null` = not tracked for this item/list. - `PUT /api/price-lists/{id}/items/{sku}` accepts the extended body `{ "price": ..., "minPrice": ..., "retailPrice": ..., "agencyMarginPercent": ... }` — both new fields are optional and independent of each other and of `minPrice`. - `PriceResolution` (returned by `ResolvePriceAsync`/`ResolveByStoreAsync`) carries both fields through so the frontend can display them without a second lookup; both are `null` on the `Item.SalePrice` fallback path, since neither is tracked outside an actual `PriceListItem`. --- ## Data Model Changes ### PriceList ``` PriceList ├── Id (Guid) — PK, from Template ├── Name (string) ├── FK_RegionId (Guid?) — nullable, null = global (applies to all regions) ├── EffectiveFrom (DateTime) ├── EffectiveTo (DateTime) ├── IsActive, CreatedAt — from Template ``` ### PriceListItem ``` PriceListItem ├── Id (Guid) — PK, from Template ├── FK_PriceListId (Guid) — required ├── FK_ItemId (Guid) — required ├── Price (decimal) — ceiling/list price, overrides Item.SalePrice for this item while │ the parent PriceList is active and applicable to the order's region ├── MinPrice (decimal?) — nullable floor for negotiated pricing; null = Price is fixed. │ Must be <= Price when set. ├── RetailPrice (decimal?) — nullable MRP the store charges the end consumer; informational, │ not enforced on sales. Must be >= Price when set. ├── AgencyMarginPercent (decimal?) — nullable, 0-100, the regional agency's commission % of Price; │ informational only, feeds agency settlement reporting. ├── IsActive, CreatedAt — from Template ``` Unique constraint on `(PriceListId, ItemId)` — one price per item per price list. ### Supplier ``` Supplier ├── Id (Guid) — PK, from Template ├── Name (string) ├── ContactInfo (string?) — free text (phone/email/address), matches ContactInfo pattern │ used elsewhere rather than splitting into separate columns ├── PerformanceScore (decimal?) — nullable, populated by 15_procurement.md's evaluateSupplier; │ this step only defines the column and leaves it null-only ├── IsActive, CreatedAt — from Template ``` --- ## Migrations One migration needed: 1. **AddPricingAndSuppliers** — creates `PriceLists`, `PriceListItems`, `Suppliers` tables. --- ## API Endpoints > Permission column names the target policy for each route; enforcement is **not implemented yet** > — see [Permissions to Add to Seed](#permissions-to-add-to-seed). Endpoints require > authentication only until seeding is turned on. ### Price Lists | Method | Route | Permission | Notes | |---|---|---|---| | GET | /api/price-lists | pricing.view | Filterable by region, active-on-date | | GET | /api/price-lists/{id} | pricing.view | Includes items | | POST | /api/price-lists | pricing.create | `createPriceList(name, region, effectiveDates)` | | PUT | /api/price-lists/{id} | pricing.update | Rejects if new dates overlap another price list for any shared `(ItemId, RegionId)` pair | | DELETE | /api/price-lists/{id} | pricing.delete | Soft delete | | PUT | /api/price-lists/{id}/items/{sku} | pricing.update | `updateProductPricing(sku, priceListId, price)` — upserts the `PriceListItem` row | | DELETE | /api/price-lists/{id}/items/{sku} | pricing.update | Removes an item's override from the price list | ### Suppliers | Method | Route | Permission | Notes | |---|---|---|---| | GET | /api/suppliers | suppliers.view | | | GET | /api/suppliers/{id} | suppliers.view | | | POST | /api/suppliers | suppliers.create | `createSupplierMaster(supplierData)` | | PUT | /api/suppliers/{id} | suppliers.update | Cannot set `PerformanceScore` directly — that field is read-only via this endpoint, only 15_procurement.md's `evaluateSupplier` writes it | | DELETE | /api/suppliers/{id} | suppliers.delete | Soft delete, blocked if any PurchaseOrder references it (enforced once 15_procurement.md exists; no-op check until then) | --- ## Request / Response Shapes ### POST `/api/price-lists` ```json { "name": "North Region Q3", "regionId": "...", "effectiveFrom": "2026-09-01", "effectiveTo": "2026-09-30" } ``` `regionId` omitted or null = global price list. ### PUT `/api/price-lists/{id}/items/{sku}` ```json { "price": 125.50, "minPrice": 115.00, "retailPrice": 150.00, "agencyMarginPercent": 15 } ``` Resolves `sku` to `ItemId`, upserts the `PriceListItem` row. `minPrice`, `retailPrice`, and `agencyMarginPercent` are all optional — omit or send `null` for none. Rejected with 409 if `minPrice > price`, `retailPrice < price`, or `agencyMarginPercent` is outside `[0, 100]`. ### POST `/api/suppliers` ```json { "name": "Acme Distributors", "contactInfo": "acme@example.com, +91-98765-43210" } ``` --- ## Business Rules 1. `applyPricingAndDiscounts` resolves the unit price for an order line in this order: region-scoped active price list → global active price list → `Item.SalePrice` → reject if all three are absent (order creation requires an explicit price, per rule 10 of [01_item_catalog.md](./01_item_catalog.md)). - "Active" means `EffectiveFrom <= orderDate <= EffectiveTo` and `IsActive = true`. - "Region-scoped" means `PriceList.RegionId` equals the order's store's region. - If more than one region-scoped price list is active for the same region (shouldn't happen given rule 2, but defensively), the most recently created one wins. 2. Two active price lists cannot have overlapping `EffectiveFrom`/`EffectiveTo` ranges for the same `(ItemId, RegionId)` pair — checked at the `PriceListItem` level (a global list and a region list with the same item may coexist since region-scoped always wins in resolution, so the overlap check only compares price lists with the *same* `RegionId` value, including both being null). 3. A `PriceList` cannot be deleted (soft delete) while any `PriceListItem` under it is still active — delete/deactivate the items first, or cascade-deactivate them in the same transaction (cascade chosen: deleting a price list deactivates all its `PriceListItem` rows in the same unit-of-work). 4. `Supplier.PerformanceScore` is null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` sets it; no endpoint in this step writes a non-null value. 5. A `Supplier` cannot be hard-deleted, ever — soft delete only, matching the `Template.IsActive` convention used across the codebase. 6. `PriceListItem.MinPrice`, when set, must be `<= Price` — enforced on every write via `PUT /api/price-lists/{id}/items/{sku}` (409 if violated). 7. At order time, a caller-supplied `OrderItemRequest.UnitPrice` must fall within `[MinPrice ?? Price, Price]` for the item's resolved `PriceListItem` (region-scoped, then global). The `Item.SalePrice` fallback has no `MinPrice` — always fixed, no negotiation. An out-of-band price, or no resolvable price at all, rejects the whole order (400). 8. `PriceListItem.RetailPrice`, when set, must be `>= Price` — enforced on every write via `PUT /api/price-lists/{id}/items/{sku}` (409 if violated); a store must never be expected to sell below what it paid. 9. `PriceListItem.AgencyMarginPercent`, when set, must be within `[0, 100]` — enforced the same way (409 if violated). 10. Neither `RetailPrice` nor `AgencyMarginPercent` participates in order pricing/validation (rule 7) — both are informational only, carried through `PriceResolution` for display and reporting, and are `null` on the `Item.SalePrice` fallback path. --- ## Permissions to Add to Seed **Status: Not implemented for now.** Endpoints in this step are built without permission enforcement (no `[Authorize(Policy = ...)]` gate) until explicitly requested. The codes and role assignments below are the target design to wire in later — keep them here as the reference, not as something to seed yet. Target permission codes (not yet in `RoleAndPermissionSeed.cs`): ``` pricing.view, pricing.create, pricing.update, pricing.delete suppliers.view, suppliers.create, suppliers.update, suppliers.delete ``` Target role assignments (mirrors the [01_item_catalog.md](./01_item_catalog.md) items/categories pattern), for when seeding is turned on: - **SalesRep (10)**: pricing.view - **Supervisor (20)**: pricing.view - **AreaManager (30)**: pricing.view, pricing.create, pricing.update, suppliers.view - **AreaSupervisor (40)**: all pricing + supplier permissions - **RegionalManager (50)**: all pricing + supplier permissions - **RegionalAgency (60)**: all pricing + supplier permissions --- ## Open Questions - None. Resolved in planning discussion (2026-08-20): price lists are region-only in v1 (no `Store.Segment` scoping); deleting a price list cascades to deactivate its items rather than being blocked by them. --- ## Implementation Notes ### File structure created / touched ``` Features/ PriceList/ PriceListController.cs PriceListDTOs.cs PriceListService.cs (ResolveUnitPriceAsync — the price-list half of applyPricingAndDiscounts) IPriceListRepository.cs PriceListRepository.cs Supplier/ SupplierController.cs SupplierDTOs.cs SupplierService.cs ISupplierRepository.cs SupplierRepository.cs Order/ OrderDTOs.cs (extended — OrderItemRequest gained an optional UnitPrice override for the SalePrice-is-null / no-price-list-match case; OrderItemResponse now echoes the captured UnitPrice) OrderService.cs (extended — CreateAsync resolves each line's price via a private ResolveUnitPriceAsync that calls PriceListService, using the request's UnitPrice override if supplied; rejects the order (returns null) if no price can be resolved at all) Item/ IItemRepository.cs / ItemRepository.cs (extended — GetBySkuAsync, needed to resolve the {sku} route segment on the price-list item endpoints) Store/ IStoreRepository.cs / StoreRepository.cs (extended — GetByIdWithRegionAsync, includes Store.Route.Region so OrderService can read the store's RegionId for price resolution) Models/ PriceList.cs (new) PriceListItem.cs (new) Supplier.cs (new) Data/AppDBContext.cs add PriceLists, PriceListItems, Suppliers DbSets; unique index on PriceListItem(FK_PriceListId, FK_ItemId) Migrations/ AddPricingAndSuppliers (applied) Program.cs register IPriceListRepository/PriceListRepository, ISupplierRepository/SupplierRepository, PriceListService, SupplierService Data/Seed/ RoleAndPermissionSeed.cs — untouched. Permission enforcement is intentionally deferred (see Permissions to Add to Seed above); controllers use plain [Authorize] with no policy for now. ``` ### Price resolution logic (pseudo) ``` ResolveUnitPriceAsync(itemId, storeId, orderDate): regionId = store.RegionId regionList = active PriceListItem where ItemId = itemId and PriceList.RegionId = regionId and PriceList.EffectiveFrom <= orderDate <= PriceList.EffectiveTo and PriceList.IsActive and PriceListItem.IsActive order by PriceList.CreatedAt desc, take 1 if regionList found: return regionList.Price globalList = same query with PriceList.RegionId = null if globalList found: return globalList.Price if item.SalePrice != null: return item.SalePrice return null // caller (OrderService) rejects with 400 if no explicit price was supplied ``` ### Overlap validation on price list write (pseudo) ``` On PUT /api/price-lists/{id}/items/{sku} or POST /api/price-lists: for each ItemId affected, check existing PriceListItem rows joined to PriceList where PriceList.RegionId = this.RegionId (both null counts as a match) and PriceList.Id != this.Id and date ranges overlap (existing.EffectiveFrom <= new.EffectiveTo and existing.EffectiveTo >= new.EffectiveFrom) → reject with 400 if any match found ``` --- ## Worked Example Item `COKE-330` has `SalePrice = 100`. Store `S1` is in the North region. **Set up a negotiable region price list:** ``` POST /api/price-lists { "name": "North Region September", "regionId": "", "effectiveFrom": "2026-09-01", "effectiveTo": "2026-09-30" } → PriceList A (RegionId = North) PUT /api/price-lists/{A.Id}/items/COKE-330 { "price": 90, "minPrice": 80, "retailPrice": 120, "agencyMarginPercent": 15 } ``` This publishes a band for `COKE-330` in the North region: ceiling 90, floor 80. It also records that the store is expected to retail it at 120 (so the store's own margin is 90–120, or up to 40 if the rep negotiated down to 80), and that the North region's agency retains 15% of whatever wholesale price was actually charged (e.g. 15% of 90 = 13.50 if sold at the ceiling) as their distribution commission — informational only, used for agency settlement reporting, not deducted automatically from anything. **Order at the default (no negotiation):** ``` POST /api/orders { "storeId": "S1", "orderDate": "2026-09-15", "items": [{ "itemId": "COKE-330", "quantity": 10 }] } ``` No `unitPrice` supplied → resolves to the ceiling, **unit price = 90**. **Order with a negotiated price inside the band:** ``` POST /api/orders { "storeId": "S1", "orderDate": "2026-09-15", "items": [{ "itemId": "COKE-330", "quantity": 10, "unitPrice": 83 }] } ``` 83 is within `[80, 90]` → **accepted, unit price = 83**. **Order with a price outside the band:** ``` POST /api/orders { "storeId": "S1", "orderDate": "2026-09-15", "items": [{ "itemId": "COKE-330", "quantity": 10, "unitPrice": 75 }] } ``` 75 is below `MinPrice = 80` → **the whole order is rejected (400)**, same as if no price could be resolved at all. A store in the South region (no region price list, so it falls through to the global list or `Item.SalePrice = 100`) has no `MinPrice` unless a global `PriceListItem` also sets one — an override there is rejected unless it equals the fixed price exactly. **Supplier example** (unrelated to pricing resolution, same step): ``` POST /api/suppliers { "name": "Acme Distributors", "contactInfo": "acme@example.com, +91-98765-43210" } → Supplier { Id, Name: "Acme Distributors", PerformanceScore: null } ``` `PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` runs against this supplier.