19 KiB
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
not already handled by 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) 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) 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 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) 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.
PriceListItem.MinPrice (decimal?)—null= no negotiation, the item is fixed atPrice.PUT /api/price-lists/{id}/items/{sku}accepts{ "price": ..., "minPrice": ... }; rejected with 409 ifMinPrice > Price.OrderService.CreateAsyncvalidates any caller-suppliedOrderItemRequest.UnitPriceagainst[MinPrice ?? Price, Price]for that item's resolved price (region-scoped list → global list; theItem.SalePricefallback has noMinPriceand 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 wholesalePricecan 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 ofPricethe 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 sameRegionalAgencyrole that raises Agency Claims (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>= Pricewhen 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 ofminPrice.PriceResolution(returned byResolvePriceAsync/ResolveByStoreAsync) carries both fields through so the frontend can display them without a second lookup; both arenullon theItem.SalePricefallback path, since neither is tracked outside an actualPriceListItem.
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:
- AddPricingAndSuppliers — creates
PriceLists,PriceListItems,Supplierstables.
API Endpoints
Permission column names the target policy for each route; enforcement is not implemented yet — see 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
{ "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}
{ "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
{ "name": "Acme Distributors", "contactInfo": "acme@example.com, +91-98765-43210" }
Business Rules
applyPricingAndDiscountsresolves 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).- "Active" means
EffectiveFrom <= orderDate <= EffectiveToandIsActive = true. - "Region-scoped" means
PriceList.RegionIdequals 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.
- "Active" means
- Two active price lists cannot have overlapping
EffectiveFrom/EffectiveToranges for the same(ItemId, RegionId)pair — checked at thePriceListItemlevel (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 sameRegionIdvalue, including both being null). - A
PriceListcannot be deleted (soft delete) while anyPriceListItemunder 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 itsPriceListItemrows in the same unit-of-work). Supplier.PerformanceScoreis null until 15_procurement.md'sevaluateSuppliersets it; no endpoint in this step writes a non-null value.- A
Suppliercannot be hard-deleted, ever — soft delete only, matching theTemplate.IsActiveconvention used across the codebase. PriceListItem.MinPrice, when set, must be<= Price— enforced on every write viaPUT /api/price-lists/{id}/items/{sku}(409 if violated).- At order time, a caller-supplied
OrderItemRequest.UnitPricemust fall within[MinPrice ?? Price, Price]for the item's resolvedPriceListItem(region-scoped, then global). TheItem.SalePricefallback has noMinPrice— always fixed, no negotiation. An out-of-band price, or no resolvable price at all, rejects the whole order (400). PriceListItem.RetailPrice, when set, must be>= Price— enforced on every write viaPUT /api/price-lists/{id}/items/{sku}(409 if violated); a store must never be expected to sell below what it paid.PriceListItem.AgencyMarginPercent, when set, must be within[0, 100]— enforced the same way (409 if violated).- Neither
RetailPricenorAgencyMarginPercentparticipates in order pricing/validation (rule 7) — both are informational only, carried throughPriceResolutionfor display and reporting, and arenullon theItem.SalePricefallback 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 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.Segmentscoping); 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": "<northRegionId>",
"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's evaluateSupplier
runs against this supplier.