Item Pricing — price levels, price history & cost-of-sale
Pricing in zerp has three independent layers: (1) price levels — per-item sales prices keyed by a customer tier (
Master(key="price-level")), the basis of order-time sales price resolution; (2) price history (item_prices) — an append-only cost-price log written automatically by purchase posting; and (3) costing — the algorithm (STANDARD/AVCO/FIFO/LIFO) that derives cost-of-sale from the purchase lots at sale time. The whole sales-price resolution reduces to a short fallback chain (UOM price → customer price level → item default level → item.price), and the whole cost-of-sale reduces to one switch onitem.costingMethod.
Source: BE src/modules/inventory/item/{price-level,price,costing} · Admin src/modules/item/{price-level,pricing} · price-level vocabulary is Master(key="price-level") in src/modules/master
1. Purpose & scope
| Layer | BE module | Collection | What it owns |
|---|---|---|---|
| Price level | inventory/item/price-level |
item_price_levels |
per-item sales price per customer tier; one tier may be the item's default |
| Price history | inventory/item/price |
item_prices |
append-only cost/sales price snapshots; written by purchase posting, not by users |
| Costing | inventory/item/costing |
(no collection — reads order_items) |
derives cost-of-sale (COGS) at sale time per costingMethod |
It does NOT: define the price-level vocabulary (Master(key="price-level") — see ../master-data/), hold the item's own default price (item.price — see ./item.md), carry per-UOM prices (those live on ItemUOMConversion.salesPrice/purchasePrice — see ./categories-uom.md §4.4), or move stock (see ./stock.md).
Three prices, three homes — don't conflate:
item.price— the item-level default sales price (on theItemmaster).ItemPriceLevel.salesPrice— tier price (Retail/Wholesale/VIP) per item.ItemUOMConversion.salesPrice— per-UOM price (e.g. RM/CTN), in base/alt unit terms.
2. Data model
2.1 item_price_levels — per-item tier pricing
inventory/item/price-level/price-level.schema.ts. Extends BaseSchema, mongoose-delete, timestamps. One row per (item, price-level tier).
@ApSchema({ collection: "item_price_levels", timestamps: true })
export class ItemPriceLevel extends BaseSchema {
itemId: ObjectId; // REQUIRED — → items
priceLevelId: ObjectId; // REQUIRED — → Master(key="price-level") (Retail, Wholesale, VIP)
salesPrice: number; // default 0 — the tier's sales price for this item
minQuantity: number; // default 0 — min qty for this price to apply (future quantity breaks; NOT enforced yet)
isDefault: boolean; // default false — fallback tier when the customer has no price level; one per item
}| field | type | req? | default | description |
|---|---|---|---|---|
itemId |
ObjectId | yes | — | → items |
priceLevelId |
ObjectId | yes | — | → Master(key="price-level") |
salesPrice |
number | — | 0 |
tier price for this item |
minQuantity |
number | — | 0 |
reserved for quantity breaks — stored but not consulted by resolvePrice |
isDefault |
boolean | — | false |
item-level fallback tier; at most one per item (service-enforced) |
Joins ($lookupItemFromPriceLevel → item, $lookupPriceLevelMaster → priceLevel from masters). The GraphQL ItemPriceLevel exposes resolve-field priceLevel: Master (the tier name/key).
2.2 item_prices — append-only price history (cost log)
inventory/item/price/price.schema.ts. Extends BaseSchema, soft-delete, timestamps.
@ApSchema({ collection: "item_prices", timestamps: true })
export class ItemPrice extends BaseSchema {
costPrice: number; // moving/average cost snapshot at the time
salesPrice: number;
itemId: ObjectId; // REQUIRED — → items
}| field | type | req? | description |
|---|---|---|---|
costPrice |
number | — | cost price snapshot (the running average after a purchase) |
salesPrice |
number | — | sales price snapshot (carried from the prior row when present) |
itemId |
ObjectId | yes | → items |
documentDate |
number | — | (from BaseSchema) effective date; used by getItemCostPrice(date) |
This is a log, not editable master data. Rows are appended by the purchase/order average-cost routine (see §4.2). The only user-facing mutation is deleteItemPrice (audited). getItemCostPrice(itemId, date) reads the last row with documentDate ≤ date — i.e. the cost as of a point in time.
2.3 Costing — algorithm only (no collection)
inventory/item/costing/costing.service.ts. ItemCostingService has no schema; it reads order_items lots (OrderItemRepository) and computes COGS per the item's costingMethod. The method enum lives on the Item master:
// item.scheme.ts
export enum CostingMethod { STANDARD = "STANDARD", AVERAGE = "AVERAGE", FIFO = "FIFO", LIFO = "LIFO" }
AVERAGEis surfaced in admin/exports as "AVCO" — the stored enum value isAVERAGE(see./item.md§10).
3. API surface
3.1 Item Price Level GraphQL (price-level/price-level.resolver.ts)
Resolver is @ApGqlAuthorize() + @UseGuards(GqlFeatureGuard) + @RequireFeature("MANAGE_PRICE_LEVELS") — the entire resolver is subscription-feature-gated.
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
itemPriceLevelPage |
Query | ItemPriceLevelPageInput (itemId,skip,take) |
ItemPriceLevelPageResult |
MANAGE_PRICE_LEVELS |
itemPriceLevels |
Query | itemId |
[ItemPriceLevel] |
MANAGE_PRICE_LEVELS |
createItemPriceLevel |
Mutation | input: CreateItemPriceLevelInput |
ItemPriceLevel |
+ audit CREATE |
updateItemPriceLevel |
Mutation | id, input: UpdateItemPriceLevelInput |
ItemPriceLevel |
+ audit UPDATE |
deleteItemPriceLevel |
Mutation | id |
Boolean | + audit DELETE |
Resolve-field priceLevel joins the Master by priceLevelId.
# schema.gql (generated)
input CreateItemPriceLevelInput {
itemId: String!
priceLevelId: String!
salesPrice: Float!
minQuantity: Float = 0
isDefault: Boolean = false
}
input UpdateItemPriceLevelInput { salesPrice: Float minQuantity: Float isDefault: Boolean }As with UOM conversions, the item form does not call these mutations directly — it nests
priceLevels: [UpsertItemPriceLevelInput]increateItem/updateItem, and the item resolver fans out tocreatePriceLevel/updatePriceLevelviasavePriceLevels(see./item.md§6). Standalone editing usesuseItemPriceLevelQuery(itemId)in the admin.
3.2 Item Price (history) GraphQL (price/price.resolver.ts, @ApGqlAuthorize())
| Operation | Type | Input | Returns |
|---|---|---|---|
itemPricePage |
Query | ItemPricePageInput (itemId,skip,take) |
ItemPricePageResult (joined with item) |
deleteItemPrice |
Mutation | id |
Boolean (audit DELETE) |
There is no createItemPrice mutation — history rows are written internally by purchase posting.
3.3 Costing
No GraphQL surface. ItemCostingService.getCostForSale(item, saleItem) is called by SalesItemService during sales posting (see §4.3). ItemPriceService.getAverage(itemId, kind) (delegating to OrderService.getAverage) is exposed via the Item.avg resolve-field (./item.md §3.1).
4. Business rules & calculations
4.1 Price level — single-default invariant + resolution chain
Single default (createPriceLevel/updatePriceLevel): if isDefault is set true, clearDefault(itemId) flips every existing isDefault:true row for that item to false — so at most one default tier per item.
Sales price resolution — the heart of order-time pricing (ItemPriceLevelService.resolvePrice):
// price-level.service.ts → resolvePrice(itemId, priceLevelId?)
if (priceLevelId) {
const levelPrice = await this.findOne({ itemId, priceLevelId }); // 1. customer's tier
if (levelPrice) return levelPrice.salesPrice;
}
const defaultPrice = await this.findOne({ itemId, isDefault: true }); // 2. item's default tier
return defaultPrice ? defaultPrice.salesPrice : null; // 3. null → caller uses item.priceResolution order (sales):
- Customer's price level — match
ItemPriceLevelon the customer'spriceLevelId.- Item default tier — the row with
isDefault:true.null— caller falls back toitem.price.
minQuantityis not consulted (quantity breaks are reserved/unimplemented). The match is exact onpriceLevelId; there is no nearest-tier logic.
Where the customer's tier comes from: Customer.priceLevelId (customer/customer.schema.ts, → Master(key="price-level")). The sales item service reads it off the populated order (order.customer.priceLevelId).
4.2 Price history — automatic average-cost write (purchase posting)
item_prices rows are appended during purchase posting when the running average cost changes. Two writers, same shape (order/order.base.ts and purchase/purchase.service.ts):
// after computing the new moving-average cost `newAvg` for the item:
if (price?.costPrice !== newAvg /* && newAvg !== 0 (purchase path) */) {
await this.itemPriceSvc.create({ ...price, costPrice: newAvg, itemId, documentDate });
}order.base.ts(OrderKindTypes.PurchaseOrderaverage):average = getAverage(itemId, PurchaseOrder); if there is available stock use the average, else the linerate. Writes a new row only ifcostPricechanged.purchase.service.ts(purchase-invoice moving average):newAvg = (availableStock > 1 && prevGrossWeight > 1) ? (newPurchaseCost + prevAmount) / (netQuantity + prevGrossWeight) : rate. Writes only whencostPricechanged andnewAvg !== 0.
Moving average (OrderService.getAverage(itemId, kind)): totalAmount(itemId, itemKind) / totalQty(itemId, itemKind) over order_items (qty in netQuantity), 0 when no quantity. This is the AVCO basis used both for the price-history snapshot and (independently) by ItemCostingService AVCO.
4.3 Costing — cost-of-sale per method (the COGS math)
ItemCostingService.getCostForSale(item, saleItem) switches on item.costingMethod ?? STANDARD:
switch (costingMethod) {
case STANDARD: return (item.cost || 0) * (saleItem.netQuantity || 0);
case AVERAGE: return getAvcoCost(item, saleItem);
case FIFO: return getLotCost(item, saleItem, "asc");
case LIFO: return getLotCost(item, saleItem, "desc");
default: return (item.cost || 0) * (saleItem.netQuantity || 0);
}All quantities are base units (the sale line has already been UOM-converted, see ./categories-uom.md §4.4). branchId, when present on the sale item, scopes the lot lookup. Lots = order_items where kind = PurchaseInvoiceItem.
STANDARD — cost = item.cost × qty. No DB reads.
AVERAGE (AVCO) — weighted average over all purchase lots dated ≤ sale date:
unitCost = Σ(lot.amount) / Σ(lot.netQuantity) // uses `amount` (real spend), NOT lot.cost (often 0)
cost = unitCost × saleQty // falls back to item.cost if no lots
Worked (spec): lots (10 @ 50) + (20 @ 60) → totalQty 30, totalCost 1700, unit 56.67; sell 3 → ≈ 170.
FIFO / LIFO (getLotCost) — consume lots oldest-first (asc) / newest-first (desc), skipping quantity already consumed by prior sales (sales dated < this sale), using per-lot amount/netQuantity as unit cost; exhausted demand falls back to item.cost:
priorSalesQty = Σ netQuantity of sales with documentDate < saleDate
remaining = priorSalesQty // these units are already gone from the front
for lot in lots (sorted asc=FIFO / desc=LIFO):
if remaining >= lot.qty: remaining -= lot.qty; continue // lot fully consumed by prior sales
available = lot.qty - remaining; remaining = 0
take = min(available, toSell); cost += take × (lot.amount/lot.qty); toSell -= take
if toSell > 0: cost += toSell × item.cost // ran out of lots → fallback
Worked (spec, FIFO): lots (10@50),(20@60), no prior sales, sell 12 → 10×50 + 2×60 = 620. With 8 units of prior sales, sell 5 → lot1 leaves 2 @50=100, then 3 @60=180 → 280. LIFO sell 5 (no prior) → 5×60 = 300.
Costing reads
amount, notcost.order_items.costis 0 for non-"fixed" items; the actual purchase spend lives inamount, so AVCO/FIFO/LIFO useamount / netQuantityas the true unit cost. A port that useslot.costwill compute zero COGS for most items.
4.4 Order-time price resolution (sales) — end to end
In SalesItemService.addInvoiceItem (sales/item/item.service.ts), after UOM-converting netQuantity to base units (factor recorded):
if (!model.fixed && order?.customerId) {
const resolvedPrice = await priceLevelSvc.resolvePrice(itemId, order.customer?.priceLevelId);
if (resolvedPrice !== null) {
amount = resolvedPrice * netQuantity; // price-level price is PER BASE UNIT
model.rate = resolvedPrice * conversionFactor; // scale back up to the line UOM
priceLevelId = order.customer.priceLevelId; // stamp the tier used onto the line
}
}
// cost-of-sale for the same line:
return this.costingSvc.getCostForSale(item, model); // COGS per item.costingMethod (§4.3)Full sales-price fallback chain (effective):
model.fixed→ use the entered amount/rate verbatim (manual override, no resolution).- else customer price level → item default level (
resolvePrice).- else
null→ the line keeps its enteredamount/rate(which defaults fromitem.price). A configured per-UOM price (ItemUOMConversion.salesPrice) is resolved byresolveUOMSalesPrice(./categories-uom.md§4.4) where the order UI/flow uses it to pre-fill the line rate before this step.Price-level prices are stored per base unit, so
rate = price × conversionFactorconverts back to the entered UOM andamount = price × netQuantity(base). COGS is computed independently by the costing service.
4.5 Status / state machine
None. Price levels and price history are plain reference/log data with no posting lifecycle. Costing is a pure read-time computation.
4.6 Side effects & transactionality
- Price level writes: reference-data only; audit snapshot per mutation; soft-delete.
- Price history writes: side-effect of purchase posting, inside the purchase transaction; never user-initiated (except delete).
- Costing: read-only; no writes. Runs inside the sales posting transaction as a calculation step.
5. Permissions
| Resolver | Decorators | Feature gate |
|---|---|---|
| Price Level | @ApGqlAuthorize() + @AuditMeta |
@RequireFeature("MANAGE_PRICE_LEVELS") |
| Price (history) | @ApGqlAuthorize() + @AuditMeta (delete) |
— |
| Costing | (no resolver) | — |
MANAGE_PRICE_LEVELS is subscription-driven via GqlFeatureGuard — without it the price-level resolver is inaccessible and orders fall back to item.price / entered rate. See ../../platform/permissions-access.md, ../../platform/audit-trail.md, and ./_overview.md §7.
6. Flows
6.1 Configure price levels on an item (admin → nested write)
- Admin item create/edit form,
MANAGE_PRICE_LEVELS-gated Price Level tab. Each row picks apriceLevel(Master), asalesPrice, optionalminQuantity, and anisDefaulttoggle. - On submit,
priceLevelsare nested increateItem/updateItem; the item resolver callssavePriceLevels→createItemPriceLevel(no_id) /updateItemPriceLevel(has_id). SettingisDefaultclears any prior default for the item. - Standalone editing:
useItemPriceLevelQuery(itemId)(price-level/gql/query.ts) —itemPriceLevelsquery +create/update/deletePriceLevelmutations, eachrefetch()-ing.
6.2 Sales posting price resolution (read → ledger write)
Order(kind=SalesInvoice)line →SalesItemService.addInvoiceItem. Non-inventory items skip stock (see./stock.md).- UOM-convert entered qty → base units (factor recorded) —
./categories-uom.md§4.4. - Price resolve (§4.4): if not
fixedand order has a customer →resolvePrice(itemId, customer.priceLevelId); setamount = price × baseQty,rate = price × factor, stamppriceLevelId. Else keep entered amount/rate. - Cost of sale:
costingSvc.getCostForSale(item, line)peritem.costingMethod(§4.3). - Availability guard, then
Stock(type=OUT)in base units (see./stock.md).
6.3 Purchase posting → price history (read → log write)
Order(kind=PurchaseInvoice)line posts; moving average recomputed (§4.2).- If the new average differs from the last logged
costPrice(purchase path: and≠ 0), append anitem_pricesrow{ costPrice: newAvg, itemId, documentDate }. Item.avgresolve-field and AVCO costing read this history / theorder_itemslots.
6.4 View pricing report (admin read-only)
- Admin
PricingPage(item/pricing/page.tsx) →fetchItemPricings→itemPricePage(theitem_priceslog joined toitem). Columns: Item Name, Sales Price, Cost Price, Date. - Read-only listing with duration filter + XLSX/PDF download. (
removeItemPricingexists in context but the delete action column is commented out — listing is effectively view-only.)
7. Admin UI
Routes / contexts:
- Price levels have no standalone page — they live in the item form's Price Level tab (
MANAGE_PRICE_LEVELS). Data hook:price-level/gql/query.tsuseItemPriceLevelQuery(itemId)→priceLevels,createPriceLevel,updatePriceLevel,deletePriceLevel(Apollo,refetchafter writes). Fragment (price-level/gql/fragment.ts) selectssalesPrice,minQuantity,isDefault, and the joinedpriceLevel { _id name key }. - Pricing report →
/maintenance/item/pricing→PricingPage+ItemPricingTable(pricing/page.tsx,pricing/components/table.tsx). ContextuseItemPricingState:fetchItemPricings,removeItemPricing,filter,itemPricings,totalRecords. ReadsitemPricePage. - Costing has no dedicated UI —
costingMethodis set on the item form; the item table renders Cost as the AVCOavgwhencostingMethod === AVERAGE(./item.md§7).
Notable UX: the pricing page item filter is a stub (hardcoded "Item 1/2/3" options); the table delete action is commented out (view-only history). Price-level rows are managed inline in the item form, with the single-default invariant enforced server-side.
8. Dependencies & integrations
- Price level reads
MasterService(resolve thepriceLeveltier) and is read bySalesItemService(resolvePriceat posting). Customer tier comes fromCustomer.priceLevelId. - Price history is written by
OrderService/PurchaseService(average-cost), read byItem.avgandgetItemCostPrice(date). - Costing reads
OrderItemRepository(order_itemslots); called bySalesItemService. - Wired into
ItemModule:ItemPriceModule,ItemPriceLevelModule,ItemCostingModule(./item.md§9). - Emits audit events (price-level + price-delete). No external services.
9. Gotchas & project-specific rules
- Three distinct sales prices (
item.price,ItemPriceLevel.salesPrice,ItemUOMConversion.salesPrice) — keep their roles separate; resolution order matters. resolvePriceignoresminQuantity— quantity breaks are reserved but unimplemented; the match is an exactpriceLevelIdlookup, no nearest-tier fallback beyond the single item default.- Price-level prices are per base unit — the order line multiplies by
conversionFactorto get the UOM rate. Storing a per-UOM number here will double-convert. item_pricesis a log, not master data — no create mutation; rows appended only when the moving average changes during purchase posting.item.cost/item.priceremain the editable masters.- Costing uses
order_items.amount, not.cost—.costis 0 for non-fixed items; AVCO/FIFO/LIFO useamount / netQuantity. A port keying offcostcomputes near-zero COGS. - FIFO/LIFO net out prior sales by
documentDate < saleDate; missingdocumentDatefalls back toDate.now()(logged warning) — backdated entries can mis-layer; ensure dates are set. AVERAGEdisplays as "AVCO" — enum value isAVERAGE.MANAGE_PRICE_LEVELSgates the whole price-level resolver — without the subscription feature, price levels can't be managed and sales falls back toitem.price/ entered rate.fixedlines bypass price resolution entirely (manual price override).- See
./categories-uom.mdfor UOM conversion (qty/rate scaling),./item.mdforitem.price/item.cost/costingMethod, and./stock.mdfor how resolved prices/costs land on ledger rows.