Inventory Costing — how unit cost is captured, stored, consumed, and posted to the GL
The whole costing subsystem reduces to one idea: every item carries a
costingMethod(STANDARD | AVERAGE | FIFO | LIFO) that decides, on each stock issue, what unit cost is charged to COGS. Receipts capture cost (FIFO pushes a layer, AVERAGE re-derivesitem.cost, STANDARD compares actual-vs-standard); issues consume that cost; landed costs adjust it after the fact. There is no single "valuation" number stored — value is always re-derived from theStockledger plus the layers/lots inside it.
Source: BE src/modules/inventory/stock (costing.service.ts, stock.repository.ts, cost-layer.dto.ts), src/modules/inventory/item/costing (costing.service.ts — ItemCostingService), src/modules/inventory/landed-cost, costing call-sites in src/modules/inventory/purchase/item/item.service.ts and src/modules/inventory/sales/item/item.service.ts · Admin src/modules/inventory/landed-cost (+ the FIFO cost-layers report under src/modules/report)
Read the movement mechanics first in the canonical reference zerp-be/docs/inventory-stock-flow.md and the domain entry point ./_overview.md. This doc is cost only — quantity flow (IN/OUT rows, balance()) is documented there and in ./stock.md.
⚠️ Two services named
CostingService. They are different and both matter:
CostingServiceininventory/stock/costing.service.ts— stateful FIFO engine. Pushes/consumesStock.costLayers[], recomputes AVERAGE, posts STANDARD variance, and adjusts cost for landed costs. Injected asfifoSvcat the call-sites.ItemCostingServiceininventory/item/costing/costing.service.ts— stateless lot calculator. Computes a sale's COGS for STANDARD/AVERAGE/FIFO/LIFO by queryingorder_itemshistory. Injected ascostingSvc.For FIFO the call-sites use the stateful engine (
fifoSvc.postIssue) and bypassItemCostingService. For STANDARD/AVERAGE/LIFO the statelessItemCostingServicedoes the math. See §6.
1. Purpose & scope
This module is responsible for:
- Selecting a costing method per item (
Item.costingMethod, defaultAVERAGE). - Capturing unit cost on stock receipts (purchase invoice lines, production completion).
- Determining cost of goods sold (COGS) on stock issues (sales invoice lines, backflush material consumption).
- Landed-cost allocation — distributing freight/duty/insurance across purchase-order lines and folding it into item cost.
- Posting the cost legs to the GL (inventory / cost-of-sales / variance accounts).
- Surfacing inventory valuation reports per method (FIFO / AVCO / LIFO / Standard).
It explicitly does not:
- Store on-hand quantity (always
Σ IN − Σ OUTfrom the ledger — see./stock.md). - Own the price/selling-side resolution (that is
./pricing.md). - Implement an
avgcollection — the top-levelsrc/modules/avgmodule is an empty stub (Avgschema has no fields;AvgServiceis a bare CRUD shell). All real averaging lives inCostingService/ItemCostingService/OrderService.getPurchaseAvg. Do not look for costing logic inavg.
2. Data model
There is no dedicated costing collection. Cost data is embedded on three existing places:
2.1 Item.costingMethod + Item.cost (collection items)
inventory/item/item.scheme.ts
| field | type | required | description |
|---|---|---|---|
costingMethod |
CostingMethod enum |
no (default AVERAGE) |
the per-item method that drives all costing decisions |
cost |
number |
no | the item's current unit cost. For AVERAGE items this is the live weighted-average (rewritten on every receipt). For STANDARD items this is the standard cost. Used as the fallback unit cost everywhere else. |
costPrice |
number |
no | legacy mirror, default 0 |
salesAccountId |
ObjectId |
no | GL revenue account (credit on sale) |
costOfSalesAccountId |
ObjectId |
no | GL COGS account (debit on sale) — also reused as the PPV / variance credit account for standard-cost variance |
inventoryAccountId |
ObjectId |
no | GL inventory asset account (credit on sale, debit on purchase) |
// inventory/item/item.scheme.ts
export enum CostingMethod {
STANDARD = "STANDARD",
AVERAGE = "AVERAGE",
FIFO = "FIFO",
LIFO = "LIFO",
}
@Prop({ type: String, enum: CostingMethod, default: CostingMethod.AVERAGE })
costingMethod: CostingMethod;Legacy
costByAverageboolean. The original implementation switched on a boolean. Theinventory-costing-typesplan replaced it withCostingMethod. The stockCostingServiceresolvesitem.costingMethod || AVERAGE(boolean ignored). The plan-eraItemCostingServiceonce fell back toitem.costByAverage ? AVERAGE : STANDARDwhencostingMethodwas null; the current shippedItemCostingService.getCostForSaleusesitem.costingMethod ?? CostingMethod.STANDARD— i.e. STANDARD is the no-method default for sales costing, while AVERAGE is the schema default. Mind this asymmetry (see §9).
2.2 Stock.costLayers[] — embedded FIFO cost layers (collection stocks)
inventory/stock/stock.schema.ts. Each purchase receipt pushes one layer onto the Stock document it created. Each sale decrements remainingQty on the oldest layers.
@Prop({
type: [{
receiptDate: Date,
receiptRef: String,
orderId: String,
quantity: Number, // original received qty for this layer
remainingQty:Number, // qty still un-consumed (the open balance)
unitCost: Number, // unit cost of this layer (mutated by landed cost)
}],
default: [],
})
costLayers: Array<{
receiptDate: Date; receiptRef: string; orderId?: string;
quantity: number; remainingQty: number; unitCost: number;
}>;
@Prop({ default: 0 }) cost: number; // total cost of this movement (rate × qty for sales)
@Prop({ default: 0 }) avgCost: number; // unit cost of this movement
@Prop({ default: 0 }) standardCost: number;
@Prop({ default: 0 }) pendingStandardCost: number;- A
Stockrow oftype: INmay own one or morecostLayers(one per receipt to that doc); atype: OUTrow carries no layers — it consumes them on other docs. remainingQty > 0⇒ the layer is open (still in inventory).remainingQty === 0⇒ fully consumed.- Soft-deleted (
mongoose-delete) stock rows are excluded from every aggregation, so deleting a receipt removes its layers from valuation automatically.
GraphQL projection (cost-layer.dto.ts → FifoLayer): adds a derived remainingValue = remainingQty × unitCost (computed in the report aggregation, not stored).
2.3 LandedCost (collection landed_costs)
inventory/landed-cost/landed-cost.scheme.ts
| field | type | required | description |
|---|---|---|---|
purchaseOrderId |
ObjectId |
yes | the PO whose lines receive the allocation |
description |
string |
no | e.g. "Freight inbound" |
totalAmount |
number |
yes | total extra cost to distribute (NOT per-unit) |
allocationMethod |
LandedCostAllocationMethod enum |
yes | BY_VALUE/BY_QUANTITY/BY_WEIGHT/BY_VOLUME |
status |
LandedCostStatus enum |
no (default DRAFT) |
DRAFT → ALLOCATED → POSTED |
allocations[] |
embedded | no | per-item basis + computed share (see below) |
companyId, branchId |
ObjectId |
no | tenant/branch scope, stamped from the user at create |
export enum LandedCostAllocationMethod { BY_VALUE, BY_QUANTITY, BY_WEIGHT, BY_VOLUME }
export enum LandedCostStatus { DRAFT, ALLOCATED, POSTED }
allocations: Array<{
itemId: Types.ObjectId;
allocatedAmount: number; // computed by allocate(): totalAmount × share
originalCost: number; // basis for BY_VALUE
originalQuantity?: number; // basis for BY_QUANTITY
originalWeight?: number; // basis for BY_WEIGHT
originalVolume?: number; // basis for BY_VOLUME
adjustedCost: number; // originalCost + allocatedAmount
}>;Schema vs GraphQL gap. The
LandedCostAllocationGraphQL ObjectType (landed-cost.dto.ts) only exposesitemId, allocatedAmount, originalCost, adjustedCost— theoriginalQuantity/Weight/Volumebasis fields exist in Mongo but are not in the API surface, and there is no input DTO to set them (CommonLandedCostInputonly acceptspurchaseOrderId, description, totalAmount, allocationMethod). So whileBY_QUANTITY/WEIGHT/VOLUMEare implemented inallocate(), populating their basis requires a path not exposed by the current create/update mutations. In practice onlyBY_VALUEis fully wired end-to-end. See §9.
3. API surface
Landed cost (GraphQL — landed-cost.resolver.ts, all under @ApGqlAuthorize())
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
landedCostPage(page) |
Query | LandedCostPageInput {skip, take, keyword, purchaseOrderId, status, sortBy, sortOrder} |
LandedCostPageResult {totalRecords, data} |
JWT + access group |
findLandedCost(_id) |
Query | String |
LandedCost |
JWT |
createLandedCost(landedCost) |
Mutation | CreateLandedCostInput |
LandedCost (DRAFT) |
JWT + audit(CREATE) |
updateLandedCost(_id, landedCost) |
Mutation | UpdateLandedCostInput (partial) |
LandedCost |
JWT + audit(UPDATE) |
allocateLandedCost(_id) |
Mutation | String |
LandedCost (→ ALLOCATED) |
JWT + audit(UPDATE) |
postLandedCost(_id) |
Mutation | String |
LandedCost (→ POSTED) |
JWT + audit(STATUS_CHANGE) |
deleteLandedCost(_id) |
Mutation | String |
Boolean |
JWT + audit(DELETE) |
Valuation / cost-layers reports (GraphQL — stock.resolver.ts)
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
fifoValuation(branchId) |
Query | String |
[FifoItemValuation] |
JWT (+ admin gate VIEW_FIFO_COST_LAYERS) |
avcoValuation(branchId) |
Query | String |
[AvcoItemValuation] |
JWT |
standardValuation(branchId) |
Query | String |
[StandardItemValuation] |
JWT |
lifoValuation(branchId) |
Query | String |
[LifoItemValuation] |
JWT |
branchId is the only arg; companyId is taken from @GqlCurrentUser() (tenant scoping). Return shapes are in cost-layer.dto.ts (see §7).
There are no costing-specific REST endpoints. Item.costingMethod is set through the normal createItem/updateItem mutations (see ./item.md).
4. Business rules & calculations — the actual costing math
Two entry points carry all cost logic:
CostingService.postReceipt / postIssue / adjustCost(inventory/stock/costing.service.ts) — the stateful FIFO engine + STANDARD variance + AVERAGE/landed adjustment.ItemCostingService.getCostForSale(inventory/item/costing/costing.service.ts) — the stateless per-sale COGS calculator for STANDARD/AVERAGE/FIFO/LIFO overorder_items.
4.1 Method resolution
// CostingService (receipt/issue/adjust):
const costingMethod = item.costingMethod || CostingMethod.AVERAGE;
// ItemCostingService (per-sale COGS):
const effectiveCostType = item.costingMethod ?? CostingMethod.STANDARD;4.2 STANDARD cost
Issue (COGS):
cost = item.cost × netQuantity. Flat. No history read. (ItemCostingService:case STANDARD: return (item.cost || 0) * saleItem.netQuantity.)Receipt: capture the actual purchase unit cost, compute a purchase price variance:
variance = (actualUnitCost − standardCost) × qty // standardCost = item.costIf
variance !== 0, post a journal entry (see §4.7). Favourable (actual < standard, variance negative) ⇒ debit Inventory / credit COGS; unfavourable ⇒ debit COGS / credit Inventory. Inventory stays at standard.Landed cost adjust: the full
allocatedAmountis posted as another variance JE (no per-unit spread); inventory is not re-valued.
4.3 AVERAGE (weighted-average / AVCO)
Two cooperating mechanisms:
(a) Live item.cost recompute on receipt (OrderService.getPurchaseAvg in order/order.base.ts, called from purchase/item/item.service.ts → addItemStock):
// getAverage(itemId, PurchaseOrder):
// totalAmount = Σ order_items.amount (kind = PurchaseInvoiceItem)
// totalQty = Σ order_items.netQuantity
// average = totalQty > 0 ? totalAmount / totalQty : 0
// getPurchaseAvg:
// newAvg = availableStock > 0 ? average : thisLineRate
// → writes ItemPrice{costPrice: newAvg} history row
// → purchase service then does item.update({ cost: newAvg }) (only for AVERAGE items)So item.cost for an AVERAGE item is the running weighted average = Σ(purchase line amounts) / Σ(purchase net qty) across all purchase-invoice lines for the item. On delete of a purchase line, recalcAvgCost() re-runs the same average so item.cost self-heals.
(b) COGS on issue = the weighted average as of the sale date (ItemCostingService.getAvcoCost):
lots = purchaseInvoiceItems(itemId) where documentDate ≤ saleDate (branch-scoped if set)
totalQty = Σ lot.netQuantity
totalCost = Σ lot.amount // NOTE: amount, not cost — cost is 0 for non-fixed items
unitCost = totalQty > 0 ? totalCost / totalQty : item.cost
COGS = unitCost × saleItem.netQuantityKey subtlety: AVCO uses
order_items.amount(total spend per line), notorder_items.cost, becausecostis left 0 for non-fixed-cost items. This is the same convention used in the AVCO valuation aggregation (§7).
CostingService.postReceipt for AVERAGE is a no-op (the getPurchaseAvg/item.update path already maintained item.cost); CostingService.postIssue for AVERAGE just returns item.cost × qty. Landed-cost adjust for AVERAGE simply does item.cost += totalAdditionalCost.
4.4 FIFO — layer push (receipt) and oldest-first consumption (issue)
Receipt — CostingService.postReceipt(itemId, qty, unitCost, receiptRef, branchId, stockId?, orderId?):
// FIFO branch:
let targetId = stockId; // attach to the stock doc just created by the receipt
if (!targetId) { // manufacturing/back-compat fallback:
const stocks = find({ itemId, branchId }); // → prefer the latest IN row, else latest row
targetId = lastIN?._id ?? lastStock?._id;
}
target.costLayers.push({
receiptDate: now, receiptRef, orderId,
quantity: qty, remainingQty: qty, unitCost,
});
stockRepo.update(targetId, { costLayers });The purchase call-site passes the newly created stock _id as stockId and the purchase line rate as unitCost, so each receipt's layer lands on its own document. (The original bug — appending to the latest doc — was fixed in 2026-05-19-fifo-costing-implementation.)
Issue — CostingService.postIssue → consumeFifoLayers(itemId, qty, branchId): consume open layers oldest-first (docs sorted by createdAt: 1 via findWithRemainingLayers, layers in array order):
remaining = qty; totalCost = 0;
for (stock of stocksWithOpenLayers /* sorted createdAt asc */) {
for (layer of stock.costLayers) {
if (remaining <= 0) break;
if (layer.remainingQty <= 0) continue;
consume = min(layer.remainingQty, remaining);
totalCost += consume × layer.unitCost;
layer.remainingQty -= consume; // mutate & persist
remaining -= consume;
}
if (modified) stockRepo.update(stock._id, { costLayers });
}
unitCost = qty > 0 ? totalCost / qty : 0; // weighted avg of consumed layers
return { unitCost, totalCost };Consumption order = (stock doc
createdAtascending) then (layer index). Oldest receipt's layer is consumed first; when a layer is partially consumed itsremainingQtyshrinks but the layer stays open until exhausted.totalCostis the exact sum ofconsumedQty × layerUnitCostand is what gets booked to COGS. If layers run dry mid-issue the leftover qty is simply not costed by the layer engine (no item.cost fallback inconsumeFifoLayers— contrast the statelessgetLotCost, §4.6).
Edits & voids keep layers honest:
- Purchase line update (qty/rate change, FIFO item): zero the old layers (
remainingQty: 0),postReceipta fresh layer with the new qty/rate, then re-postIssuewhatever was already consumed (alreadyConsumed = Σ(quantity − remainingQty)) so prior sales still hold their cost. (purchase/item/item.service.tsupdate path.) - Purchase line delete: zero the deleted stock's layers before soft-deleting the stock row, so
consumeFifoLayersnever sees phantom qty. - Sales line delete/void (FIFO item):
postReceipta "void-sale-…" layer restoring the consumed qty at the resolved sale unit cost — effectively putting inventory back.
4.5 LIFO — newest-first
LIFO has no live layer engine on issue. At sale time ItemCostingService.getLotCost(..., "desc") runs the same lot walk as FIFO but with lots sorted newest-first (§4.6). The LIFO valuation report reconstructs remaining layers in-memory (§7). CostingService (the stateful engine) does not special-case LIFO — only FIFO uses the layer push/consume; LIFO/AVERAGE/STANDARD go through ItemCostingService.
4.6 Lot-walk algorithm (FIFO/LIFO stateless COGS — ItemCostingService.getLotCost)
Used for FIFO when the stateful engine is not invoked (e.g. valuation/back-compat) and for LIFO always:
lots = purchaseInvoiceItems(itemId, branch?) sorted by documentDate (asc=FIFO, desc=LIFO)
sales = salesInvoiceItems(itemId, branch?)
priorSalesQty = Σ sales.netQuantity where documentDate < saleDate // qty already consumed
remaining = priorSalesQty; toSell = saleItem.netQuantity; totalCost = 0;
for (lot of lots) {
if (toSell <= 0) break;
lotQty = lot.netQuantity;
lotUnitCost = lotQty > 0 ? lot.amount / lotQty : lot.cost; // amount/qty, not lot.cost
if (remaining >= lotQty) { remaining -= lotQty; continue; } // lot fully eaten by prior sales
available = lotQty - remaining; remaining = 0;
take = min(available, toSell);
totalCost += take × lotUnitCost;
toSell -= take;
}
if (toSell > 0) totalCost += toSell × item.cost; // fallback when lots exhausted
return totalCost; // = COGS for this sale lineSame convention: unit cost is amount/qty, and leftover beyond available lots falls back to item.cost.
4.7 Side effects (what else writes when costing writes)
Standard variance JE (
CostingService.postStandardCostVariance) — a balanced two-legAccountTransaction(kind: JournalEntry,status: POSTED) using the item'sinventoryAccountIdandcostOfSalesAccountId(the latter doubles as the PPV account). Wrapped in try/catch; missing accounts ⇒ logged warning, no throw.Sales COGS legs (per inventory item, on post —
sales/item/item.service.ts → updateItemTransactions):Account Type Amount salesAccountId(revenue)CREDIT line net (ex-tax) costOfSalesAccountId(COGS)DEBIT model.cost(= COGS from §4.2–4.6)inventoryAccountId(inventory)CREDIT model.costSo COGS debit + Inventory credit = the costed COGS amount, and revenue is credited separately. Non-inventory items post only the revenue leg.
Purchase inventory leg (on add —
purchase/item/item.service.ts → updateItemInventoryTransaction): DEBITinventoryAccountIdfor the line net amount.Audit trail on every landed-cost mutation (
@AuditMeta).
4.8 Transactionality
Each purchase/sales line runs inside withRetryTransaction(...) so the order_item, the Stock row, the FIFO layer mutations, and the GL legs all commit in one Mongo session. LandedCostService.setSession propagates the session to CostingService.stockRepo so post()'s adjustCost layer updates join the same transaction.
4.9 Landed-cost allocation — distributing extra cost across lines
LandedCostService.allocate(landedCostId) (DRAFT → ALLOCATED):
basis(alloc) = { BY_QUANTITY: originalQuantity, BY_WEIGHT: originalWeight,
BY_VOLUME: originalVolume, BY_VALUE(default): originalCost }
totalBasis = Σ basis(alloc) // throws if 0
for (alloc of allocations) {
share = basis(alloc) / totalBasis
alloc.allocatedAmount = lc.totalAmount × share // proportional split
alloc.adjustedCost = (alloc.originalCost || 0) + alloc.allocatedAmount
}LandedCostService.post(landedCostId) (ALLOCATED → POSTED): for each allocation with an allocatedAmount, call CostingService.adjustCost(itemId, allocatedAmount, branchId) — i.e. push the per-item freight share into the item's cost basis:
// CostingService.adjustCost(itemId, totalAdditionalCost, branchId):
FIFO: perUnit = totalAdditionalCost / Σ(open layers' remainingQty across all stock docs)
for each open layer: layer.unitCost += perUnit // spread evenly per remaining unit
AVERAGE: item.cost += totalAdditionalCost // bump the running average
STANDARD: postStandardCostVariance(itemId, totalAdditionalCost, "landed-cost-…") // variance JEThe param is a total amount, not per-unit (it was renamed
additionalCostPerUnit → totalAdditionalCostin2026-05-19-fifo-costing-implementationafter the bug where it added the whole amount to a single layer). FIFO spreads it across only the still-open layers weighted by remaining qty; fully-consumed layers (already in COGS) are untouched.
5. State machine (Landed Cost)
createLandedCost allocateLandedCost postLandedCost
(none) ───────────────────▶ DRAFT ────────────────────▶ ALLOCATED ───────────────────▶ POSTED
│ computes allocations[] │ adjustCost() per item │ (terminal)
│ (proportional split) │ → folds freight into │
│ │ item cost / layers / GL │
guard: allocate only DRAFT ───┘ guard: post only ALLOCATED ─┘
allocatethrows if status ≠ DRAFT;postthrows if status ≠ ALLOCATED. Re-allocation requires the record to be back in DRAFT (no transition exists to demote — effectively allocate-once).postis best-effort per item: a failedadjustCostfor one item is caught and logged; the status still flips to POSTED.
6. Costing dispatch at the call-sites (who calls what)
PURCHASE (receipt) purchase/item/item.service.ts → addItemStock()
├─ getPurchaseAvg() → item.update({cost}) [AVERAGE only]
└─ fifoSvc.postReceipt(itemId, netQty, rate, ref, branch, newStockId, orderId) [FIFO only]
(STANDARD: receipt posts variance via postReceipt; AVERAGE: no-op)
SALES (issue) sales/item/item.service.ts → getCostPrice()
├─ FIFO: { totalCost } = fifoSvc.postIssue(itemId, netQty, branch) → model.cost
└─ else: model.cost = costingSvc.getCostForSale(item, saleItem) [STANDARD/AVERAGE/LIFO]
then model.cost flows to the COGS/Inventory GL legs (§4.7)
LANDED COST (post) landed-cost.service.ts → post() → fifoSvc.adjustCost(itemId, share, branch)
BACKFLUSH (mfg) stock.service.ts → backflushConsume() → fifoSvc.postIssue() → OUT row at unitCost
So for a FIFO item a sale calls the stateful postIssue (mutates layers) and never touches ItemCostingService; for STANDARD/AVERAGE/LIFO the stateless ItemCostingService.getCostForSale computes COGS from order_items history with no layer mutation.
7. Valuation & FIFO cost-layers reports
Four per-method valuation queries aggregate the Stock ledger (branch + company scoped, deleted ≠ true). Return DTOs in cost-layer.dto.ts.
7.1 FIFO cost-layers report — findFifoValuation(branchId, companyId)
The headline report (fifoValuation query; admin page below). Pipeline:
match stocks with at least one open layer (remainingQty > 0)
→ $unwind costLayers → match remainingQty > 0
→ group by itemId:
layers[] = each open layer {receiptRef, orderId, receiptDate, quantity,
remainingQty, unitCost, remainingValue = remainingQty × unitCost}
totalRemainingQty = Σ remainingQty
totalRemainingValue = Σ (remainingQty × unitCost)
→ lookup item (name, code) → sort by itemName
Output (FifoItemValuation): itemId, itemName, itemCode, totalRemainingQty, totalRemainingValue, layers[FifoLayer]. This is the literal open-FIFO valuation: the sum of remaining layers' value = inventory carrying value under FIFO.
7.2 AVCO valuation — findAvcoValuation
group ledger by itemId → balance = Σ IN.netQty − Σ (non-IN).netQty, keep balance > 0
→ lookup item, filter costingMethod = "AVERAGE"
→ lookup order_items (PurchaseInvoiceItem): totalCost = Σ amount, totalQty = Σ netQuantity
→ avgCost = totalQty > 0 ? totalCost / totalQty : item.cost
→ totalValue = balance × avgCost
(Same amount-based weighted average as §4.3b.)
7.3 Standard valuation — findStandardValuation
balance per item (IN − OUT), keep > 0 → lookup item
→ filter costingMethod ∉ {AVERAGE, FIFO, LIFO} (i.e. STANDARD / unset)
→ standardCost = item.cost, totalValue = balance × item.cost
7.4 LIFO valuation — findLifoValuation
Hybrid (aggregation + in-memory):
balance per item (IN − OUT), keep > 0 → lookup item, filter costingMethod = "LIFO"
→ lookup purchaseLayers (PurchaseInvoiceItem) sorted documentDate asc,
each {receiptRef=ref, orderId, receiptDate, quantity=netQty, unitCost=amount/netQty}
→ in-memory: toConsume = Σ layer.quantity − balance (qty sold)
consume from NEWEST layers first (reverse loop) decrementing remainingQty
activeLayers = layers with remainingQty > 0, remainingValue = remainingQty × unitCost
totalRemainingQty / totalRemainingValue = Σ over activeLayers
So LIFO remaining = the oldest layers (because newest were consumed), valued at their original amount/qty unit costs.
7.5 Admin — FIFO Cost Layers report page
Per 2026-05-20-fifo-cost-layers-report, the admin adds (under the report module, not landed-cost):
report/model.ts:IFifoLayer,IFifoItemValuation.report/gql/{fragment,query}.ts:FifoItemValuationFragment,FIFO_VALUATIONquery +useFifoValuationQueryhook (lazy, network-only).report/context.tsx:fifoValuationstate +fetchFifoValuation(branchId).report/components/cost-layers-table.tsx: an Ant Design expandable table — parent rows = items (code, name, open qty, total value); expanded rows = the layers (receipt ref, date, orig qty, remaining qty, unit cost, remaining value).report/inventory/cost-layers.tsx+pages/report/cost-layers.tsx: branch selector → fetch on branch change; guarded byINVENTORY_REPORT.VIEW_FIFO_COST_LAYERS.
8. Admin UI — Landed Cost module
Source: zerp-admin/src/modules/inventory/landed-cost (page.tsx, context.tsx, model.ts, gql/, components/{table,create}.tsx). Follows the zync-nextjs context pattern (useLandedCostState() → useLandedCostQuery() → Apollo).
- Page (
page.tsx):ApPageHeader+ "Create Landed Cost" button →ApModalwithCreateLandedCostform; search input;LandedCostTable. - Context (
context.tsx): exposesfetchLandedCosts,saveLandedCost(create/update split on_id),deleteLandedCost, and modal/filter state. - GQL hook (
gql/query.ts): wrapslandedCostPage,findLandedCost,create,update,allocate,post,remove. - Enum labels in
model.ts:AllocationMethodLabelsmaps the four methods to "By Value / By Quantity / By Weight / By Volume".
Context gap:
useLandedCostQuery()returnsallocateandpostmutations, but the context only wires CRUD —allocate/postare not re-exposed throughuseLandedCostState. Per the standard, surfacing the allocate/post workflow in the UI means extending the context to call those mutations (andreload()after) rather than reaching past it. As written, the admin can create/edit/delete landed costs but the allocate→post lifecycle is not driven from the page.
9. Gotchas & project-specific rules
- Two
CostingServiceclasses (§ top banner).fifoSvc(stock) is stateful + FIFO-only mutation;costingSvc(item) is stateless STANDARD/AVERAGE/LIFO (+ FIFO fallback) math. Don't merge them. avgmodule is an empty stub. No costing logic there — ignore it.- Default-method asymmetry. Schema default is
AVERAGE(Item.costingMethod), butItemCostingService.getCostForSaletreats a missing method asSTANDARD. Items always created throughcreateItemgetAVERAGE; only legacy/null rows hit the STANDARD fallback. - AVCO/LIFO/FIFO-lot use
amount, nevercost.order_items.costis 0 for non-fixed-cost items; the actual purchase unit cost isamount / netQuantity. Every average and lot-walk usesamount. - FIFO layers live on the receiving
Stockdoc, keyed by the new stock_idpassed asstockId. Get this wrong (append to latest) and layers attach to the wrong document — the original implementation bug. - FIFO consume has no item.cost fallback (
consumeFifoLayers), but the stateless lot-walk (getLotCost) does. A FIFO sale exceeding open layers under-costs the leftover (0), whereas LIFO/back-compat falls back toitem.cost. - Landed cost is total, not per-unit, and only spreads over open FIFO layers (consumed qty already in COGS is not retroactively adjusted).
BY_VALUEis the only method fully reachable through the current GraphQL input surface (no basis inputs for qty/weight/volume). - STANDARD never re-values inventory — receipts and landed costs only post variance journal entries (favourable/unfavourable) against inventory ↔︎ COGS; on-hand inventory stays at standard cost.
- Cost → COGS GL always books two cost legs on a sale (DEBIT COGS + CREDIT Inventory at
model.cost) plus a separate revenue CREDIT; non-inventory items skip both cost legs. - Soft-delete = auto-reversal. Deleting a receipt/sale removes its
Stockrow (and its layers) from every aggregation, so valuation andbalance()self-correct; FIFO additionally zeroes layers before delete and restores them on sale-void to keep open balances exact.
Cross-links
- Quantity ledger & movement mechanics:
./stock.md,zerp-be/docs/inventory-stock-flow.md - Item catalog, accounts,
costingMethodselection:./item.md - Selling-price resolution (the price side, not cost):
./pricing.md - Domain map & cross-module flows:
./_overview.md - GL transactions, journal entries, accounts:
../finance/ - Permissions / audit:
../../platform/permissions-access.md,../../platform/audit-trail.md