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 on item.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 the Item master).
  • 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 ($lookupItemFromPriceLevelitem, $lookupPriceLevelMasterpriceLevel 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" }

AVERAGE is surfaced in admin/exports as "AVCO" — the stored enum value is AVERAGE (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] in createItem/updateItem, and the item resolver fans out to createPriceLevel/updatePriceLevel via savePriceLevels (see ./item.md §6). Standalone editing uses useItemPriceLevelQuery(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.price

Resolution order (sales):

  1. Customer's price level — match ItemPriceLevel on the customer's priceLevelId.
  2. Item default tier — the row with isDefault:true.
  3. null — caller falls back to item.price.

minQuantity is not consulted (quantity breaks are reserved/unimplemented). The match is exact on priceLevelId; 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.PurchaseOrder average): average = getAverage(itemId, PurchaseOrder); if there is available stock use the average, else the line rate. Writes a new row only if costPrice changed.
  • purchase.service.ts (purchase-invoice moving average): newAvg = (availableStock > 1 && prevGrossWeight > 1) ? (newPurchaseCost + prevAmount) / (netQuantity + prevGrossWeight) : rate. Writes only when costPrice changed and newAvg !== 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.

STANDARDcost = 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, not cost. order_items.cost is 0 for non-"fixed" items; the actual purchase spend lives in amount, so AVCO/FIFO/LIFO use amount / netQuantity as the true unit cost. A port that uses lot.cost will 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):

  1. model.fixed → use the entered amount/rate verbatim (manual override, no resolution).
  2. else customer price level → item default level (resolvePrice).
  3. else null → the line keeps its entered amount/rate (which defaults from item.price). A configured per-UOM price (ItemUOMConversion.salesPrice) is resolved by resolveUOMSalesPrice (./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 × conversionFactor converts back to the entered UOM and amount = 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)

  1. Admin item create/edit form, MANAGE_PRICE_LEVELS-gated Price Level tab. Each row picks a priceLevel (Master), a salesPrice, optional minQuantity, and an isDefault toggle.
  2. On submit, priceLevels are nested in createItem/updateItem; the item resolver calls savePriceLevelscreateItemPriceLevel (no _id) / updateItemPriceLevel (has _id). Setting isDefault clears any prior default for the item.
  3. Standalone editing: useItemPriceLevelQuery(itemId) (price-level/gql/query.ts) — itemPriceLevels query + create/update/deletePriceLevel mutations, each refetch()-ing.

6.2 Sales posting price resolution (read → ledger write)

  1. Order(kind=SalesInvoice) line → SalesItemService.addInvoiceItem. Non-inventory items skip stock (see ./stock.md).
  2. UOM-convert entered qty → base units (factor recorded) — ./categories-uom.md §4.4.
  3. Price resolve (§4.4): if not fixed and order has a customer → resolvePrice(itemId, customer.priceLevelId); set amount = price × baseQty, rate = price × factor, stamp priceLevelId. Else keep entered amount/rate.
  4. Cost of sale: costingSvc.getCostForSale(item, line) per item.costingMethod (§4.3).
  5. Availability guard, then Stock(type=OUT) in base units (see ./stock.md).

6.3 Purchase posting → price history (read → log write)

  1. Order(kind=PurchaseInvoice) line posts; moving average recomputed (§4.2).
  2. If the new average differs from the last logged costPrice (purchase path: and ≠ 0), append an item_prices row { costPrice: newAvg, itemId, documentDate }.
  3. Item.avg resolve-field and AVCO costing read this history / the order_items lots.

6.4 View pricing report (admin read-only)

  1. Admin PricingPage (item/pricing/page.tsx) → fetchItemPricingsitemPricePage (the item_prices log joined to item). Columns: Item Name, Sales Price, Cost Price, Date.
  2. Read-only listing with duration filter + XLSX/PDF download. (removeItemPricing exists 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.ts useItemPriceLevelQuery(itemId)priceLevels, createPriceLevel, updatePriceLevel, deletePriceLevel (Apollo, refetch after writes). Fragment (price-level/gql/fragment.ts) selects salesPrice, minQuantity, isDefault, and the joined priceLevel { _id name key }.
  • Pricing report/maintenance/item/pricingPricingPage + ItemPricingTable (pricing/page.tsx, pricing/components/table.tsx). Context useItemPricingState: fetchItemPricings, removeItemPricing, filter, itemPricings, totalRecords. Reads itemPricePage.
  • Costing has no dedicated UI — costingMethod is set on the item form; the item table renders Cost as the AVCO avg when costingMethod === 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 the priceLevel tier) and is read by SalesItemService (resolvePrice at posting). Customer tier comes from Customer.priceLevelId.
  • Price history is written by OrderService/PurchaseService (average-cost), read by Item.avg and getItemCostPrice(date).
  • Costing reads OrderItemRepository (order_items lots); called by SalesItemService.
  • 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.
  • resolvePrice ignores minQuantity — quantity breaks are reserved but unimplemented; the match is an exact priceLevelId lookup, no nearest-tier fallback beyond the single item default.
  • Price-level prices are per base unit — the order line multiplies by conversionFactor to get the UOM rate. Storing a per-UOM number here will double-convert.
  • item_prices is a log, not master data — no create mutation; rows appended only when the moving average changes during purchase posting. item.cost/item.price remain the editable masters.
  • Costing uses order_items.amount, not .cost.cost is 0 for non-fixed items; AVCO/FIFO/LIFO use amount / netQuantity. A port keying off cost computes near-zero COGS.
  • FIFO/LIFO net out prior sales by documentDate < saleDate; missing documentDate falls back to Date.now() (logged warning) — backdated entries can mis-layer; ensure dates are set.
  • AVERAGE displays as "AVCO" — enum value is AVERAGE.
  • MANAGE_PRICE_LEVELS gates the whole price-level resolver — without the subscription feature, price levels can't be managed and sales falls back to item.price / entered rate.
  • fixed lines bypass price resolution entirely (manual price override).
  • See ./categories-uom.md for UOM conversion (qty/rate scaling), ./item.md for item.price/item.cost/costingMethod, and ./stock.md for how resolved prices/costs land on ledger rows.