use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::ProductId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgingCostLayer {
pub product_id: Option<ProductId>,
pub sku: String,
pub quantity: Decimal,
pub unit_cost: Decimal,
pub received_at: NaiveDate,
}
impl AgingCostLayer {
#[must_use]
pub fn value(&self) -> Decimal {
self.quantity * self.unit_cost
}
#[must_use]
pub fn age_days(&self, as_of: NaiveDate) -> i64 {
(as_of - self.received_at).num_days().max(0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryAgingBucket {
pub label: String,
pub min_days: i64,
pub max_days: Option<i64>,
pub quantity: Decimal,
pub value: Decimal,
}
impl InventoryAgingBucket {
fn new(label: &str, min_days: i64, max_days: Option<i64>) -> Self {
Self {
label: label.to_string(),
min_days,
max_days,
quantity: Decimal::ZERO,
value: Decimal::ZERO,
}
}
#[must_use]
pub const fn contains(&self, age_days: i64) -> bool {
if age_days < self.min_days {
return false;
}
match self.max_days {
Some(max) => age_days <= max,
None => true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryAgingReport {
pub as_of: NaiveDate,
pub buckets: Vec<InventoryAgingBucket>,
pub total_quantity: Decimal,
pub total_value: Decimal,
}
#[must_use]
pub fn compute_inventory_aging(
layers: &[AgingCostLayer],
as_of: NaiveDate,
) -> InventoryAgingReport {
let mut buckets = vec![
InventoryAgingBucket::new("0-30", 0, Some(30)),
InventoryAgingBucket::new("31-60", 31, Some(60)),
InventoryAgingBucket::new("61-90", 61, Some(90)),
InventoryAgingBucket::new("90+", 91, None),
];
let mut total_quantity = Decimal::ZERO;
let mut total_value = Decimal::ZERO;
for layer in layers {
let age = layer.age_days(as_of);
let value = layer.value();
total_quantity += layer.quantity;
total_value += value;
if let Some(bucket) = buckets.iter_mut().find(|b| b.contains(age)) {
bucket.quantity += layer.quantity;
bucket.value += value;
}
}
InventoryAgingReport { as_of, buckets, total_quantity, total_value }
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use rust_decimal_macros::dec;
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn layer(sku: &str, qty: Decimal, cost: Decimal, received: NaiveDate) -> AgingCostLayer {
AgingCostLayer {
product_id: None,
sku: sku.into(),
quantity: qty,
unit_cost: cost,
received_at: received,
}
}
#[test]
fn age_days_clamped() {
let l = layer("a", dec!(1), dec!(1), day(2026, 7, 1));
assert_eq!(l.age_days(day(2026, 6, 1)), 0);
assert_eq!(l.age_days(day(2026, 7, 31)), 30);
}
#[test]
fn buckets_assign_by_age() {
let as_of = day(2026, 6, 30);
let layers = vec![
layer("fresh", dec!(10), dec!(2), day(2026, 6, 20)), layer("mid", dec!(5), dec!(4), day(2026, 5, 10)), layer("old", dec!(2), dec!(10), day(2026, 1, 1)), ];
let report = compute_inventory_aging(&layers, as_of);
assert_eq!(report.total_quantity, dec!(17));
assert_eq!(report.total_value, dec!(20) + dec!(20) + dec!(20));
let b0 = &report.buckets[0];
assert_eq!(b0.label, "0-30");
assert_eq!(b0.quantity, dec!(10));
assert_eq!(b0.value, dec!(20));
let b1 = &report.buckets[1];
assert_eq!(b1.quantity, dec!(5));
assert_eq!(b1.value, dec!(20));
let b3 = &report.buckets[3];
assert_eq!(b3.label, "90+");
assert_eq!(b3.quantity, dec!(2));
assert_eq!(b3.value, dec!(20));
assert_eq!(report.buckets[2].quantity, dec!(0));
}
#[test]
fn boundary_ages() {
let as_of = day(2026, 6, 30);
let at_30 = layer("x", dec!(1), dec!(1), as_of - chrono::Duration::days(30));
let at_31 = layer("y", dec!(1), dec!(1), as_of - chrono::Duration::days(31));
let report = compute_inventory_aging(&[at_30, at_31], as_of);
assert_eq!(report.buckets[0].quantity, dec!(1));
assert_eq!(report.buckets[1].quantity, dec!(1));
}
#[test]
fn empty_report() {
let report = compute_inventory_aging(&[], day(2026, 6, 30));
assert_eq!(report.total_quantity, dec!(0));
assert_eq!(report.buckets.len(), 4);
}
}