Functional Weave
Code in Rust

inventory.valuation-weighted-average@1.0.0

impl/typescript.ts

2,758 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { epochDayFromIso } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { type RoundingMode, roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, assertSameCurrency, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { type AverageCostValuation, type StockMovement } from "./inventory_valuation_weighted_average_types.ts";

/**
 * Perpetual weighted average cost. The stock's value is held exactly and each
 * issue takes value x issued / on hand, rounded once, so the last issue takes
 * exactly what is left and an empty item is worth nothing.
 */
export function weightedAverageValuation(movements: readonly StockMovement[], currency: string, mode: RoundingMode): AverageCostValuation {
  const zero = money(0, currency);
  roundDiv(0, 1, mode); // an unknown mode fails even on a ledger with no issues
  let quantity = 0;
  let value = 0;
  let costOfSales = 0;
  const issueCosts: Money[] = [];
  let previousDay: number | null = null;
  let previousDate = "";
  for (const line of movements) {
    const day = epochDayFromIso(line.date);
    if (previousDay !== null && day < previousDay) {
      throw new RangeError(`movements must be in date order: ${line.date} comes after ${previousDate}`);
    }
    previousDay = day;
    previousDate = line.date;
    if (!Number.isInteger(line.quantity) || line.quantity === 0) {
      throw new RangeError(`quantity must be a non-zero whole number, received ${line.quantity} on ${line.date}`);
    }
    if (line.quantity > 0) {
      if (line.unitCost === null || line.unitCost === undefined) {
        throw new RangeError(`a receipt needs a unitCost: ${line.quantity} on ${line.date}`);
      }
      assertSameCurrency(zero, line.unitCost);
      if (line.unitCost.minor < 0) {
        throw new RangeError(`unitCost must not be negative, received ${line.unitCost.minor} on ${line.date}`);
      }
      quantity += line.quantity;
      value += line.quantity * line.unitCost.minor;
      continue;
    }
    if (line.unitCost !== null && line.unitCost !== undefined) {
      throw new RangeError(`an issue takes its cost from stock, so its unitCost must be null: ${line.quantity} on ${line.date}`);
    }
    const issued = -line.quantity;
    if (issued > quantity) {
      throw new RangeError(`insufficient stock: an issue of ${issued} on ${line.date} exceeds the ${quantity} on hand`);
    }
    const cost = roundDiv(value * issued, quantity, mode);
    issueCosts.push(money(cost, currency));
    costOfSales += cost;
    value -= cost;
    quantity -= issued;
  }
  return {
    closingQuantity: quantity,
    closingValue: money(value, currency),
    costOfSales: money(costOfSales, currency),
    issueCosts,
    averageUnitCost: money(quantity === 0 ? 0 : roundDiv(value, quantity, mode), currency),
  };
}