use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::ProductId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CogsLine {
pub product_id: Option<ProductId>,
pub sku: String,
pub quantity: Decimal,
pub revenue: Decimal,
pub cost: Decimal,
pub transaction_date: NaiveDate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CogsRow {
pub sku: String,
pub quantity: Decimal,
pub revenue: Decimal,
pub cogs: Decimal,
pub gross_margin: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionCogsReport {
pub from: NaiveDate,
pub to: NaiveDate,
pub rows: Vec<CogsRow>,
pub total_revenue: Decimal,
pub total_cogs: Decimal,
pub gross_margin: Decimal,
pub gross_margin_pct: Decimal,
}
#[must_use]
pub fn gross_margin_pct(revenue: Decimal, gross_margin: Decimal) -> Decimal {
if revenue == Decimal::ZERO {
return Decimal::ZERO;
}
(gross_margin / revenue * Decimal::from(100)).round_dp(2)
}
#[must_use]
pub fn compute_transaction_cogs(
lines: &[CogsLine],
from: NaiveDate,
to: NaiveDate,
) -> TransactionCogsReport {
use std::collections::BTreeMap;
let mut by_sku: BTreeMap<String, CogsRow> = BTreeMap::new();
let mut total_revenue = Decimal::ZERO;
let mut total_cogs = Decimal::ZERO;
for line in lines {
if line.transaction_date < from || line.transaction_date > to {
continue;
}
total_revenue += line.revenue;
total_cogs += line.cost;
let row = by_sku.entry(line.sku.clone()).or_insert_with(|| CogsRow {
sku: line.sku.clone(),
quantity: Decimal::ZERO,
revenue: Decimal::ZERO,
cogs: Decimal::ZERO,
gross_margin: Decimal::ZERO,
});
row.quantity += line.quantity;
row.revenue += line.revenue;
row.cogs += line.cost;
row.gross_margin = row.revenue - row.cogs;
}
let mut rows: Vec<CogsRow> = by_sku.into_values().collect();
rows.sort_by(|a, b| b.gross_margin.cmp(&a.gross_margin).then(a.sku.cmp(&b.sku)));
let gross_margin = total_revenue - total_cogs;
TransactionCogsReport {
from,
to,
rows,
total_revenue,
total_cogs,
gross_margin,
gross_margin_pct: gross_margin_pct(total_revenue, gross_margin),
}
}
#[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 line(sku: &str, qty: Decimal, rev: Decimal, cost: Decimal, date: NaiveDate) -> CogsLine {
CogsLine {
product_id: None,
sku: sku.into(),
quantity: qty,
revenue: rev,
cost,
transaction_date: date,
}
}
#[test]
fn margin_pct_helper() {
assert_eq!(gross_margin_pct(dec!(100), dec!(40)), dec!(40.00));
assert_eq!(gross_margin_pct(dec!(0), dec!(0)), dec!(0));
assert_eq!(gross_margin_pct(dec!(3), dec!(1)), dec!(33.33));
}
#[test]
fn aggregates_and_margins() {
let from = day(2026, 6, 1);
let to = day(2026, 6, 30);
let lines = vec![
line("a", dec!(2), dec!(100), dec!(60), day(2026, 6, 5)),
line("a", dec!(1), dec!(50), dec!(30), day(2026, 6, 10)),
line("b", dec!(5), dec!(500), dec!(100), day(2026, 6, 15)),
];
let report = compute_transaction_cogs(&lines, from, to);
assert_eq!(report.total_revenue, dec!(650));
assert_eq!(report.total_cogs, dec!(190));
assert_eq!(report.gross_margin, dec!(460));
assert_eq!(report.rows[0].sku, "b");
assert_eq!(report.rows[0].gross_margin, dec!(400));
assert_eq!(report.rows[1].sku, "a");
assert_eq!(report.rows[1].revenue, dec!(150));
assert_eq!(report.rows[1].gross_margin, dec!(60));
}
#[test]
fn date_range_filters() {
let from = day(2026, 6, 1);
let to = day(2026, 6, 30);
let lines = vec![
line("a", dec!(1), dec!(100), dec!(50), day(2026, 5, 31)),
line("a", dec!(1), dec!(100), dec!(50), day(2026, 6, 15)),
];
let report = compute_transaction_cogs(&lines, from, to);
assert_eq!(report.total_revenue, dec!(100));
}
#[test]
fn empty_report_zero_margin() {
let report = compute_transaction_cogs(&[], day(2026, 6, 1), day(2026, 6, 30));
assert_eq!(report.total_revenue, dec!(0));
assert_eq!(report.gross_margin_pct, dec!(0));
assert!(report.rows.is_empty());
}
}