inventory.valuation-fifo
FIFO stock valuation and cost of sales from a stock movement ledger, in exact money.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 18 tests, run in TypeScript, Python and Rust.
What it does
First in, first out: each issue is costed from the oldest stock still on hand, so what remains is valued at the most recent purchase prices. This is the method IAS 2 and FRS 102 section 13 allow alongside weighted average cost (LIFO is not permitted under either).
The ledger is one item's movements in date order: receipts carry a unit cost, issues carry none and take their cost from the layers they consume. An issue that spans several receipts is costed from each in turn, oldest first, and a receipt that is partly used stays as a smaller layer. The result gives the closing quantity and value, the remaining layers, the cost of sales, and the cost of each issue in ledger order (to post each one to the ledger).
For example
fifo_valuation(movements ×5, GBP)→ closing quantity 70, closing value £385.00, cost of sales £965.00, issue costs £620.00, £345.00, layers ×1 an issue spans two receipts, a later one spans the rest of the second and a thirdfifo_valuation(movements ×3, GBP)→ closing quantity 10, closing value £20.00, cost of sales £10.00, issue costs £10.00, layers ×1 an issue that exactly empties the oldest layer removes itfifo_valuation(movements ×3, GBP)→ closing quantity 0, closing value £0.00, cost of sales £16.00, issue costs £16.00, layers issuing everything leaves no layers and nothing to value
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn fifo_valuation(movements: &[StockMovement], currency: &str) -> FifoValuation
| movements | StockMovement[] | the ledger for one item, in date order; same-day lines in the order they happened |
| currency | string | the valuation currency, so an empty ledger still has one |
| returns | FifoValuation |
The types it declares, generated into your project
/// One line of the stock ledger.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StockMovement {
pub date: String,
/// positive for a receipt, negative for an issue; never zero
pub quantity: i64,
/// the cost of one unit on a receipt; null on an issue, which takes its cost from stock
pub unit_cost: Option<Money>,
}
/// Units still in stock from one receipt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StockLayer {
/// the date of the receipt
pub date: String,
pub quantity: i64,
pub unit_cost: Money,
}
/// What is left, what it is worth, and what the issues cost.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FifoValuation {
pub closing_quantity: i64,
pub closing_value: Money,
/// the cost of every issue together
pub cost_of_sales: Money,
/// the cost of each issue, in ledger order
pub issue_costs: Vec<Money>,
/// what is left, oldest first
pub layers: Vec<StockLayer>,
}
Your code names it in one line, in the file that uses it
fune!(inventory.valuation-fifo@^1); // then call fifo_valuation(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::epoch_day_from_iso; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
/// Value stock first in, first out, and cost each issue from the oldest layers.
///
/// Every cost is a whole quantity times a unit cost, so nothing is rounded and
/// closing value plus cost of sales always equals what was received.
///
/// # Panics
/// Panics on a ledger out of date order, a zero quantity, a receipt without a
/// unit cost or with a negative one, an issue with a unit cost, a cost in
/// another currency, or an issue larger than the stock on hand.
pub fn fifo_valuation(movements: &[StockMovement], currency: &str) -> FifoValuation {
let zero = money(0, currency);
let mut layers: Vec<StockLayer> = Vec::new();
let mut issue_costs = Vec::new();
let mut cost_of_sales: i64 = 0;
let mut previous: Option<(i64, &str)> = None;
for line in movements {
let day = epoch_day_from_iso(&line.date);
if let Some((previous_day, previous_date)) = previous {
if day < previous_day {
panic!("movements must be in date order: {} comes after {}", line.date, previous_date);
}
}
previous = Some((day, &line.date));
if line.quantity == 0 {
panic!("quantity must be a non-zero whole number, received 0 on {}", line.date);
}
if line.quantity > 0 {
let unit_cost = match &line.unit_cost {
Some(c) => c,
None => panic!("a receipt needs a unitCost: {} on {}", line.quantity, line.date),
};
assert_same_currency(&zero, unit_cost);
if unit_cost.minor < 0 {
panic!("unitCost must not be negative, received {} on {}", unit_cost.minor, line.date);
}
layers.push(StockLayer {
date: line.date.clone(),
quantity: line.quantity,
unit_cost: unit_cost.clone(),
});
continue;
}
if line.unit_cost.is_some() {
panic!(
"an issue takes its cost from stock, so its unitCost must be null: {} on {}",
line.quantity, line.date
);
}
let mut wanted = -line.quantity;
let on_hand: i64 = layers.iter().map(|l| l.quantity).sum();
if wanted > on_hand {
panic!(
"insufficient stock: an issue of {} on {} exceeds the {} on hand",
wanted, line.date, on_hand
);
}
let mut cost: i64 = 0;
while wanted > 0 {
let oldest = &mut layers[0];
let used = wanted.min(oldest.quantity);
cost += used * oldest.unit_cost.minor;
oldest.quantity -= used;
wanted -= used;
if oldest.quantity == 0 {
layers.remove(0);
}
}
issue_costs.push(money(cost, currency));
cost_of_sales += cost;
}
let closing_quantity: i64 = layers.iter().map(|l| l.quantity).sum();
let closing_value: i64 = layers.iter().map(|l| l.quantity * l.unit_cost.minor).sum();
FifoValuation {
closing_quantity,
closing_value: money(closing_value, currency),
cost_of_sales: money(cost_of_sales, currency),
issue_costs,
layers,
}
}
pub fn stock_movement_from_value(v: &Value) -> StockMovement {
let cost = v.get("unitCost");
StockMovement {
date: v.get("date").as_str().to_string(),
quantity: v.get("quantity").as_i64(),
unit_cost: if cost.is_null() { None } else { Some(money_from_value(cost)) },
}
}
pub fn fifo_valuation_to_value(result: &FifoValuation) -> Value {
Value::obj(vec![
("closingQuantity", Value::Int(result.closing_quantity)),
("closingValue", money_to_value(&result.closing_value)),
("costOfSales", money_to_value(&result.cost_of_sales)),
("issueCosts", Value::Arr(result.issue_costs.iter().map(money_to_value).collect())),
(
"layers",
Value::Arr(
result
.layers
.iter()
.map(|l| {
Value::obj(vec![
("date", Value::str(&l.date)),
("quantity", Value::Int(l.quantity)),
("unitCost", money_to_value(&l.unit_cost)),
])
})
.collect(),
),
),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let movements: Vec<StockMovement> = args[0]
.as_arr()
.iter()
.map(|v| {
if let Value::Float(f) = v.get("quantity") {
panic!("quantity must be a non-zero whole number, received {} on {}", f, v.get("date").as_str());
}
stock_movement_from_value(v)
})
.collect();
fifo_valuation_to_value(&fifo_valuation(&movements, args[1].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add inventory.valuation-fifo
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./inventory.valuation-fifo-1.0.0-rust.fune, or fetch it from a terminal with fune pull inventory.valuation-fifo@1.0.0:rust.
The whole function, every language, is one file too: inventory.valuation-fifo-1.0.0.fune, 24,834 bytes, sha256 f514605cd88d7e8ffd9f8428614b212c31641f671a5e882c438070f9332702a0. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before inventory.valuation-fifo
after — your function gets the result and the arguments, and returns the final result.
// fune: after inventory.valuation-fifo
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace dates.add-days in inventory.valuation-fifo
// fune: replace money.amount in inventory.valuation-fifo
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show inventory.valuation-fifo --steps.
// fune: step inventory.valuation-fifo after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| an issue spans two receipts, a later one spans the rest of the second and a third | movements ×5, GBP | → | closing quantity 70, closing value £385.00, cost of sales £965.00, issue costs £620.00, £345.00, layers ×1 |
| an issue that exactly empties the oldest layer removes it | movements ×3, GBP | → | closing quantity 10, closing value £20.00, cost of sales £10.00, issue costs £10.00, layers ×1 |
| issuing everything leaves no layers and nothing to value | movements ×3, GBP | → | closing quantity 0, closing value £0.00, cost of sales £16.00, issue costs £16.00, layers |
| receipts only: every layer is still there, oldest first | movements ×2, GBP | → | closing quantity 5, closing value £49.99, cost of sales £0.00, issue costs , layers ×2 |
| same-day lines are taken in ledger order: a receipt then an issue on one day | movements ×4, GBP | → | closing quantity 2, closing value £6.00, cost of sales £16.00, issue costs £2.50, £13.50, layers ×1 |
| a free receipt (a sample) is a layer at zero cost | movements ×3, GBP | → | closing quantity 1, closing value £7.00, cost of sales £7.00, issue costs £7.00, layers ×1 |
| restocking after running out starts a fresh layer | movements ×4, GBP | → | closing quantity 3, closing value £3.90, cost of sales £7.60, issue costs £5.00, £2.60, layers ×1 |
| an empty ledger is nothing, in the given currency | , EUR | → | closing quantity 0, closing value €0.00, cost of sales €0.00, issue costs , layers |
| an issue larger than the stock on hand is an error | movements ×2, GBP | → | error: insufficient stock: an issue of 11 on 2026-01-02 exceeds the 10 on hand |
| an issue before any receipt is an error | movements ×1, GBP | → | error: insufficient stock |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a ledger out of date order is an error | movements ×2, GBP | → | error: movements must be in date order: 2026-01-04 comes after 2026-01-05 |
| a receipt with no unit cost is an error | movements ×1, GBP | → | error: a receipt needs a unitCost |
| an issue with a unit cost is an error | movements ×2, GBP | → | error: its unitCost must be null |
| a zero quantity is an error | movements ×1, GBP | → | error: quantity must be a non-zero whole number |
| a fractional quantity is an error | movements ×1, GBP | → | error: quantity must be a non-zero whole number |
| a negative unit cost is an error | movements ×1, GBP | → | error: unitCost must not be negative |
| a cost in another currency is an error | movements ×1, GBP | → | error: currency mismatch |
| an impossible date is an error | movements ×1, GBP | → | error: is not a real calendar date |
More from the author
**Exact money.** Unit costs are `Money` in minor units and every cost is quantity times unit cost, so nothing is rounded and closing value plus cost of sales always equals the total of the receipts. A unit cost that has fractions of a penny cannot be expressed; use `inventory.valuation-weighted-average`, or cost in a smaller unit (per 100) and scale the quantities.
**Errors, not guesses.** An issue larger than the stock on hand is an error rather than negative stock: FIFO has no cost for units that were never received. So are a ledger out of date order, a zero quantity, a receipt with no unit cost or a negative one, an issue with a unit cost, and a cost in another currency. Returns to supplier and customer returns are not modelled: post a customer return as a receipt at the cost it was issued at.
Sources: IAS 2 *Inventories*, paragraphs 25-27 (IFRS Foundation); FRS 102 section 13 *Inventories*, paragraph 13.18 (Financial Reporting Council).
Files
| Path | Bytes |
|---|---|
| README.md | 1,705 |
| impl/python.py | 3,169 |
| impl/rust.rs | 5,090 |
| impl/typescript.ts | 3,072 |
| vectors.json | 7,102 |