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-derives item.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 the Stock ledger 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.tsItemCostingService), 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:

  • CostingService in inventory/stock/costing.service.tsstateful FIFO engine. Pushes/consumes Stock.costLayers[], recomputes AVERAGE, posts STANDARD variance, and adjusts cost for landed costs. Injected as fifoSvc at the call-sites.
  • ItemCostingService in inventory/item/costing/costing.service.tsstateless lot calculator. Computes a sale's COGS for STANDARD/AVERAGE/FIFO/LIFO by querying order_items history. Injected as costingSvc.

For FIFO the call-sites use the stateful engine (fifoSvc.postIssue) and bypass ItemCostingService. For STANDARD/AVERAGE/LIFO the stateless ItemCostingService does the math. See §6.


1. Purpose & scope

This module is responsible for:

  • Selecting a costing method per item (Item.costingMethod, default AVERAGE).
  • 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 − Σ OUT from the ledger — see ./stock.md).
  • Own the price/selling-side resolution (that is ./pricing.md).
  • Implement an avg collection — the top-level src/modules/avg module is an empty stub (Avg schema has no fields; AvgService is a bare CRUD shell). All real averaging lives in CostingService/ItemCostingService/OrderService.getPurchaseAvg. Do not look for costing logic in avg.

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 costByAverage boolean. The original implementation switched on a boolean. The inventory-costing-types plan replaced it with CostingMethod. The stock CostingService resolves item.costingMethod || AVERAGE (boolean ignored). The plan-era ItemCostingService once fell back to item.costByAverage ? AVERAGE : STANDARD when costingMethod was null; the current shipped ItemCostingService.getCostForSale uses item.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 Stock row of type: IN may own one or more costLayers (one per receipt to that doc); a type: OUT row 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.tsFifoLayer): 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 LandedCostAllocation GraphQL ObjectType (landed-cost.dto.ts) only exposes itemId, allocatedAmount, originalCost, adjustedCost — the originalQuantity/Weight/Volume basis fields exist in Mongo but are not in the API surface, and there is no input DTO to set them (CommonLandedCostInput only accepts purchaseOrderId, description, totalAmount, allocationMethod). So while BY_QUANTITY/WEIGHT/VOLUME are implemented in allocate(), populating their basis requires a path not exposed by the current create/update mutations. In practice only BY_VALUE is 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 over order_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.cost

    If 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 allocatedAmount is 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.netQuantity

Key subtlety: AVCO uses order_items.amount (total spend per line), not order_items.cost, because cost is 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.postIssueconsumeFifoLayers(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 createdAt ascending) then (layer index). Oldest receipt's layer is consumed first; when a layer is partially consumed its remainingQty shrinks but the layer stays open until exhausted. totalCost is the exact sum of consumedQty × layerUnitCost and 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 in consumeFifoLayers — contrast the stateless getLotCost, §4.6).

Edits & voids keep layers honest:

  • Purchase line update (qty/rate change, FIFO item): zero the old layers (remainingQty: 0), postReceipt a fresh layer with the new qty/rate, then re-postIssue whatever was already consumed (alreadyConsumed = Σ(quantity − remainingQty)) so prior sales still hold their cost. (purchase/item/item.service.ts update path.)
  • Purchase line delete: zero the deleted stock's layers before soft-deleting the stock row, so consumeFifoLayers never sees phantom qty.
  • Sales line delete/void (FIFO item): postReceipt a "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 line

Same 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-leg AccountTransaction (kind: JournalEntry, status: POSTED) using the item's inventoryAccountId and costOfSalesAccountId (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.cost

    So 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): DEBIT inventoryAccountId for 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 JE

The param is a total amount, not per-unit (it was renamed additionalCostPerUnit → totalAdditionalCost in 2026-05-19-fifo-costing-implementation after 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 ─┘
  • allocate throws if status ≠ DRAFT; post throws if status ≠ ALLOCATED. Re-allocation requires the record to be back in DRAFT (no transition exists to demote — effectively allocate-once).
  • post is best-effort per item: a failed adjustCost for 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_VALUATION query + useFifoValuationQuery hook (lazy, network-only).
  • report/context.tsx: fifoValuation state + 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 by INVENTORY_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 → ApModal with CreateLandedCost form; search input; LandedCostTable.
  • Context (context.tsx): exposes fetchLandedCosts, saveLandedCost (create/update split on _id), deleteLandedCost, and modal/filter state.
  • GQL hook (gql/query.ts): wraps landedCostPage, findLandedCost, create, update, allocate, post, remove.
  • Enum labels in model.ts: AllocationMethodLabels maps the four methods to "By Value / By Quantity / By Weight / By Volume".

Context gap: useLandedCostQuery() returns allocate and post mutations, but the context only wires CRUDallocate/post are not re-exposed through useLandedCostState. Per the standard, surfacing the allocate/post workflow in the UI means extending the context to call those mutations (and reload() 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 CostingService classes (§ top banner). fifoSvc (stock) is stateful + FIFO-only mutation; costingSvc (item) is stateless STANDARD/AVERAGE/LIFO (+ FIFO fallback) math. Don't merge them.
  • avg module is an empty stub. No costing logic there — ignore it.
  • Default-method asymmetry. Schema default is AVERAGE (Item.costingMethod), but ItemCostingService.getCostForSale treats a missing method as STANDARD. Items always created through createItem get AVERAGE; only legacy/null rows hit the STANDARD fallback.
  • AVCO/LIFO/FIFO-lot use amount, never cost. order_items.cost is 0 for non-fixed-cost items; the actual purchase unit cost is amount / netQuantity. Every average and lot-walk uses amount.
  • FIFO layers live on the receiving Stock doc, keyed by the new stock _id passed as stockId. 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 to item.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_VALUE is 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 Stock row (and its layers) from every aggregation, so valuation and balance() self-correct; FIFO additionally zeroes layers before delete and restores them on sale-void to keep open balances exact.