#![cfg_attr(feature = "rkyv", allow(missing_docs))]
use crate::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::Amount;
#[cfg(feature = "rkyv")]
use crate::intern::{AsDecimal, AsNaiveDate};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Cost {
#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
pub number: Decimal,
pub currency: crate::Currency,
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
pub date: Option<NaiveDate>,
pub label: Option<String>,
}
impl Cost {
#[must_use]
pub fn new(number: Decimal, currency: impl Into<crate::Currency>) -> Self {
Self {
number,
currency: currency.into(),
date: None,
label: None,
}
}
#[must_use]
pub fn new_calculated(number: Decimal, currency: impl Into<crate::Currency>) -> Self {
Self::new(number, currency)
}
#[must_use]
pub const fn with_date(mut self, date: NaiveDate) -> Self {
self.date = Some(date);
self
}
#[must_use]
pub const fn with_date_opt(mut self, date: Option<NaiveDate>) -> Self {
self.date = date;
self
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn with_label_opt(mut self, label: Option<String>) -> Self {
self.label = label;
self
}
#[must_use]
pub fn as_amount(&self) -> Amount {
Amount::new(self.number, self.currency.clone())
}
#[must_use]
pub fn total_cost(&self, units: Decimal) -> Amount {
Amount::new(units * self.number, self.currency.clone())
}
}
impl fmt::Display for Cost {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{{ {} {}", self.number, self.currency)?;
if let Some(date) = self.date {
write!(f, ", {date}")?;
}
if let Some(label) = &self.label {
write!(f, ", \"{}\"", crate::format::escape_string(label))?;
}
write!(f, "}}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CostNumber {
PerUnit {
#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
value: Decimal,
},
Total {
#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
value: Decimal,
},
PerUnitFromTotal(BookedCost),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct BookedCost {
#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
pub per_unit: Decimal,
#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
pub total: Decimal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BookedCostInvariantError {
pub per_unit: Decimal,
pub total: Decimal,
pub units: Decimal,
pub derived_total: Decimal,
pub abs_diff: Decimal,
pub tolerance: Option<Decimal>,
pub overflow: bool,
}
impl fmt::Display for BookedCostInvariantError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.overflow {
return write!(
f,
"BookedCost invariant check overflowed Decimal precision: per_unit ({}) * |units| ({}) exceeds Decimal::MAX (~7.92e28)",
self.per_unit,
self.units.abs(),
);
}
match self.tolerance {
Some(tol) => write!(
f,
"BookedCost invariant violated: per_unit ({}) * |units| ({}) = {} ≠ total ({}); abs_diff {} exceeds tolerance {}",
self.per_unit,
self.units.abs(),
self.derived_total,
self.total,
self.abs_diff,
tol,
),
None => write!(
f,
"BookedCost requires non-zero units; got per_unit ({}), total ({}), units (0)",
self.per_unit, self.total,
),
}
}
}
impl std::error::Error for BookedCostInvariantError {}
impl BookedCost {
fn check_invariant(
per_unit: Decimal,
total: Decimal,
units: Decimal,
) -> Result<(), BookedCostInvariantError> {
let units_abs = units.abs();
if units_abs.is_zero() {
return Err(BookedCostInvariantError {
per_unit,
total,
units,
derived_total: Decimal::ZERO,
abs_diff: Decimal::ZERO,
tolerance: None,
overflow: false,
});
}
let Some(derived_total) = per_unit.checked_mul(units_abs) else {
return Err(BookedCostInvariantError {
per_unit,
total,
units,
derived_total: Decimal::ZERO,
abs_diff: Decimal::ZERO,
tolerance: None,
overflow: true,
});
};
let abs_diff = (derived_total - total).abs();
let relative = total.abs() * Decimal::new(1, 24);
let tolerance = if relative > Decimal::new(1, 20) {
relative
} else {
Decimal::new(1, 20)
};
if abs_diff <= tolerance {
Ok(())
} else {
Err(BookedCostInvariantError {
per_unit,
total,
units,
derived_total,
abs_diff,
tolerance: Some(tolerance),
overflow: false,
})
}
}
#[must_use]
pub fn new(per_unit: Decimal, total: Decimal, units: Decimal) -> Self {
debug_assert!(
Self::check_invariant(per_unit, total, units).is_ok(),
"{}",
Self::check_invariant(per_unit, total, units).unwrap_err(),
);
Self { per_unit, total }
}
pub fn try_new(
per_unit: Decimal,
total: Decimal,
units: Decimal,
) -> Result<Self, BookedCostInvariantError> {
Self::check_invariant(per_unit, total, units)?;
Ok(Self { per_unit, total })
}
#[doc(hidden)]
#[must_use]
pub const fn from_archive_bytes_trusted(per_unit: Decimal, total: Decimal) -> Self {
Self { per_unit, total }
}
#[cfg(any(feature = "fuzz", test))]
#[doc(hidden)]
#[must_use]
pub const fn from_fuzz_unchecked(per_unit: Decimal, total: Decimal) -> Self {
Self { per_unit, total }
}
}
impl CostNumber {
#[must_use]
pub const fn per_unit(&self) -> Option<Decimal> {
match self {
Self::PerUnit { value } => Some(*value),
Self::PerUnitFromTotal(b) => Some(b.per_unit),
Self::Total { .. } => None,
}
}
#[must_use]
pub const fn total(&self) -> Option<Decimal> {
match self {
Self::Total { value } => Some(*value),
Self::PerUnitFromTotal(b) => Some(b.total),
Self::PerUnit { .. } => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct CostSpec {
pub number: Option<CostNumber>,
pub currency: Option<crate::Currency>,
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
pub date: Option<NaiveDate>,
pub label: Option<String>,
pub merge: bool,
}
impl CostSpec {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub const fn with_number(mut self, number: CostNumber) -> Self {
self.number = Some(number);
self
}
#[must_use]
pub fn with_currency(mut self, currency: impl Into<crate::Currency>) -> Self {
self.currency = Some(currency.into());
self
}
#[must_use]
pub const fn with_date(mut self, date: NaiveDate) -> Self {
self.date = Some(date);
self
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub const fn with_merge(mut self) -> Self {
self.merge = true;
self
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.number.is_none()
&& self.currency.is_none()
&& self.date.is_none()
&& self.label.is_none()
&& !self.merge
}
#[must_use]
pub fn matches(&self, cost: &Cost) -> bool {
if let Some(n) = self.number.and_then(|cn| cn.per_unit())
&& n != cost.number
{
return false;
}
if let Some(c) = &self.currency
&& c != &cost.currency
{
return false;
}
if let Some(d) = &self.date
&& cost.date.as_ref() != Some(d)
{
return false;
}
if let Some(l) = &self.label
&& cost.label.as_ref() != Some(l)
{
return false;
}
true
}
#[must_use]
pub fn resolve(&self, units: Decimal, date: NaiveDate) -> Option<Cost> {
let currency = self.currency.clone()?;
let number = match self.number? {
CostNumber::PerUnit { value: per } => per,
CostNumber::Total { value: total } => total / units.abs(),
CostNumber::PerUnitFromTotal(b) => b.per_unit,
};
Some(Cost {
number,
currency,
date: self.date.or(Some(date)),
label: self.label.clone(),
})
}
}
impl fmt::Display for CostSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{{")?;
let mut parts = Vec::with_capacity(5);
match self.number {
Some(CostNumber::PerUnit { value: n }) => parts.push(format!("{n}")),
Some(CostNumber::PerUnitFromTotal(b)) => parts.push(format!("{}", b.per_unit)),
Some(CostNumber::Total { value: n }) => parts.push(format!("# {n}")),
None => {}
}
if let Some(c) = &self.currency {
parts.push(c.to_string());
}
if let Some(d) = self.date {
parts.push(d.to_string());
}
if let Some(l) = &self.label {
parts.push(format!("\"{l}\""));
}
if self.merge {
parts.push("*".to_string());
}
write!(f, "{}", parts.join(", "))?;
write!(f, "}}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
crate::naive_date(year, month, day).unwrap()
}
#[test]
fn test_cost_new() {
let cost = Cost::new(dec!(150.00), "USD");
assert_eq!(cost.number, dec!(150.00));
assert_eq!(cost.currency, "USD");
assert!(cost.date.is_none());
assert!(cost.label.is_none());
}
#[test]
fn test_cost_builder() {
let cost = Cost::new(dec!(150.00), "USD")
.with_date(date(2024, 1, 15))
.with_label("lot1");
assert_eq!(cost.date, Some(date(2024, 1, 15)));
assert_eq!(cost.label, Some("lot1".to_string()));
}
#[test]
fn test_cost_total() {
let cost = Cost::new(dec!(150.00), "USD");
let total = cost.total_cost(dec!(10));
assert_eq!(total.number, dec!(1500.00));
assert_eq!(total.currency, "USD");
}
#[test]
fn test_cost_display() {
let cost = Cost::new(dec!(150.00), "USD")
.with_date(date(2024, 1, 15))
.with_label("lot1");
let s = format!("{cost}");
assert!(s.contains("150.00"));
assert!(s.contains("USD"));
assert!(s.contains("2024-01-15"));
assert!(s.contains("lot1"));
}
#[test]
fn test_cost_display_escapes_special_characters_in_label() {
let bare = Cost::new(dec!(520), "USD");
assert_eq!(format!("{bare}"), "{ 520 USD}");
let dated = Cost::new(dec!(520.00), "USD").with_date(date(2024, 1, 15));
assert_eq!(format!("{dated}"), "{ 520.00 USD, 2024-01-15}");
let quoted = Cost::new(dec!(100.00), "USD")
.with_date(date(2024, 1, 15))
.with_label("say \"hi\"");
assert_eq!(
format!("{quoted}"),
"{ 100.00 USD, 2024-01-15, \"say \\\"hi\\\"\"}"
);
let backslash = Cost::new(dec!(50.00), "USD").with_label("path\\to\\lot");
assert_eq!(
format!("{backslash}"),
"{ 50.00 USD, \"path\\\\to\\\\lot\"}"
);
let newline = Cost::new(dec!(75.00), "USD").with_label("line1\nline2");
assert_eq!(format!("{newline}"), "{ 75.00 USD, \"line1\\nline2\"}");
let plain = Cost::new(dec!(540.00), "USD")
.with_date(date(2024, 2, 15))
.with_label("lot-A");
assert_eq!(format!("{plain}"), "{ 540.00 USD, 2024-02-15, \"lot-A\"}");
}
#[test]
fn test_cost_spec_empty() {
let spec = CostSpec::empty();
assert!(spec.is_empty());
}
#[test]
fn test_cost_spec_matches() {
let cost = Cost::new(dec!(150.00), "USD")
.with_date(date(2024, 1, 15))
.with_label("lot1");
assert!(CostSpec::empty().matches(&cost));
let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
value: dec!(150.00),
});
assert!(spec.matches(&cost));
let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
value: dec!(160.00),
});
assert!(!spec.matches(&cost));
let spec = CostSpec::empty().with_currency("USD");
assert!(spec.matches(&cost));
let spec = CostSpec::empty().with_date(date(2024, 1, 15));
assert!(spec.matches(&cost));
let spec = CostSpec::empty().with_label("lot1");
assert!(spec.matches(&cost));
let spec = CostSpec::empty()
.with_number(crate::CostNumber::PerUnit {
value: dec!(150.00),
})
.with_currency("USD")
.with_date(date(2024, 1, 15))
.with_label("lot1");
assert!(spec.matches(&cost));
}
#[test]
fn test_cost_spec_resolve() {
let spec = CostSpec::empty()
.with_number(crate::CostNumber::PerUnit {
value: dec!(150.00),
})
.with_currency("USD");
let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
assert_eq!(cost.number, dec!(150.00));
assert_eq!(cost.currency, "USD");
assert_eq!(cost.date, Some(date(2024, 1, 15)));
}
#[test]
fn test_cost_spec_resolve_total() {
let spec = CostSpec::empty()
.with_number(crate::CostNumber::Total {
value: dec!(1500.00),
})
.with_currency("USD");
let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
assert_eq!(cost.number, dec!(150.00)); assert_eq!(cost.currency, "USD");
}
#[test]
fn booked_cost_new_accepts_consistent_pair() {
let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
assert_eq!(b.per_unit, dec!(30));
assert_eq!(b.total, dec!(300));
}
#[test]
fn booked_cost_new_accepts_negative_units() {
let b = BookedCost::new(dec!(30), dec!(300), dec!(-10));
assert_eq!(b.per_unit, dec!(30));
}
#[test]
#[should_panic(expected = "BookedCost invariant violated")]
fn booked_cost_new_rejects_inconsistent_pair_in_debug() {
let _ = BookedCost::new(dec!(50), dec!(300), dec!(10));
}
#[test]
#[should_panic(expected = "requires non-zero units")]
fn booked_cost_new_rejects_zero_units_in_debug() {
let _ = BookedCost::new(dec!(7), dec!(99), dec!(0));
}
#[test]
fn booked_cost_from_archive_bytes_trusted_skips_invariant() {
let b = BookedCost::from_archive_bytes_trusted(dec!(50), dec!(300));
assert_eq!(b.per_unit, dec!(50));
assert_eq!(b.total, dec!(300));
}
#[test]
fn booked_cost_from_fuzz_unchecked_skips_invariant() {
let b = BookedCost::from_fuzz_unchecked(dec!(999999), dec!(0.01));
assert_eq!(b.per_unit, dec!(999999));
assert_eq!(b.total, dec!(0.01));
}
#[test]
fn booked_cost_try_new_rejects_inconsistent_pair_with_diagnostic() {
let err = BookedCost::try_new(dec!(50), dec!(999), dec!(10))
.expect_err("expected invariant error for inconsistent input");
assert_eq!(err.per_unit, dec!(50));
assert_eq!(err.total, dec!(999));
assert_eq!(err.units, dec!(10));
assert_eq!(err.derived_total, dec!(500));
assert_eq!(err.abs_diff, dec!(499));
assert!(err.tolerance.is_some(), "tolerance must be reported");
assert!(!err.overflow, "this case is mismatch, not overflow");
let msg = format!("{err}");
assert!(msg.contains("50") && msg.contains("999") && msg.contains("500"));
}
#[test]
fn booked_cost_try_new_rejects_zero_units() {
let err = BookedCost::try_new(dec!(999999), dec!(0.01), dec!(0))
.expect_err("zero units must be rejected, not silently accepted");
assert!(err.tolerance.is_none(), "zero-units error has no tolerance");
assert!(!err.overflow, "this is zero-units, not overflow");
assert!(format!("{err}").contains("non-zero units"));
}
#[test]
fn booked_cost_try_new_accepts_consistent_pair() {
let result = BookedCost::try_new(dec!(50), dec!(500), dec!(10));
assert!(result.is_ok());
}
#[test]
#[should_panic(expected = "overflow")]
fn booked_cost_new_panics_in_debug_on_overflow() {
let huge = Decimal::from_str_exact("5000000000000000").unwrap();
let _ = BookedCost::new(huge, Decimal::from_str_exact("0.01").unwrap(), huge);
}
#[test]
fn booked_cost_try_new_surfaces_overflow_instead_of_panicking() {
let per_unit = Decimal::from_str_exact("5000000000000000").unwrap();
let units = Decimal::from_str_exact("5000000000000000").unwrap();
let total = Decimal::from_str_exact("0.01").unwrap();
let err = BookedCost::try_new(per_unit, total, units)
.expect_err("overflow must surface as Err, not panic");
assert!(err.overflow, "overflow flag must be set");
assert!(
err.tolerance.is_none(),
"no tolerance comparison performed for overflow",
);
assert_eq!(err.derived_total, Decimal::ZERO);
assert_eq!(err.abs_diff, Decimal::ZERO);
let msg = format!("{err}");
assert!(
msg.contains("overflow") || msg.contains("Decimal::MAX"),
"error message must name the overflow condition, got: {msg}"
);
}
#[test]
fn booked_cost_invariant_tolerates_rust_decimal_rounding() {
let total = dec!(300);
let units = dec!(1.763);
let per_unit = total / units;
let _ = BookedCost::new(per_unit, total, units);
}
#[test]
fn cost_number_per_unit_accessor() {
assert_eq!(
CostNumber::PerUnit { value: dec!(150) }.per_unit(),
Some(dec!(150))
);
assert_eq!(CostNumber::Total { value: dec!(1500) }.per_unit(), None);
let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
assert_eq!(CostNumber::PerUnitFromTotal(b).per_unit(), Some(dec!(30)));
}
#[test]
fn cost_number_total_accessor() {
assert_eq!(CostNumber::PerUnit { value: dec!(150) }.total(), None);
assert_eq!(
CostNumber::Total { value: dec!(1500) }.total(),
Some(dec!(1500))
);
let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
assert_eq!(CostNumber::PerUnitFromTotal(b).total(), Some(dec!(300)));
}
#[test]
fn cost_spec_resolve_per_unit_from_total_uses_per_unit_directly() {
let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
let spec = CostSpec::empty()
.with_number(CostNumber::PerUnitFromTotal(b))
.with_currency("USD");
let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
assert_eq!(cost.number, dec!(30));
assert_eq!(cost.currency, "USD");
let total_spec = CostSpec::empty()
.with_number(crate::CostNumber::Total { value: dec!(300) })
.with_currency("USD");
let total_cost = total_spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
assert_eq!(cost.number, total_cost.number);
}
#[test]
fn cost_spec_matches_per_unit_from_total() {
let cost = Cost::new(dec!(150.00), "USD")
.with_date(date(2024, 1, 15))
.with_label("lot1");
let b = BookedCost::new(dec!(150), dec!(300), dec!(2));
let spec = CostSpec::empty().with_number(CostNumber::PerUnitFromTotal(b));
assert!(spec.matches(&cost));
let wrong = BookedCost::new(dec!(160), dec!(320), dec!(2));
let wrong_spec = CostSpec::empty().with_number(CostNumber::PerUnitFromTotal(wrong));
assert!(!wrong_spec.matches(&cost));
}
#[test]
fn cost_number_serde_emits_kind_tagged_shape() {
let pu = CostNumber::PerUnit { value: dec!(100) };
let json = serde_json::to_value(pu).unwrap();
assert_eq!(json["kind"], "per_unit", "PerUnit must use kind tag");
let t = CostNumber::Total { value: dec!(1500) };
let json = serde_json::to_value(t).unwrap();
assert_eq!(json["kind"], "total");
let b = BookedCost::new(dec!(150), dec!(300), dec!(2));
let puft = CostNumber::PerUnitFromTotal(b);
let json = serde_json::to_value(puft).unwrap();
assert_eq!(json["kind"], "per_unit_from_total");
assert_eq!(json["per_unit"], "150");
assert_eq!(json["total"], "300");
}
#[test]
fn cost_number_serde_round_trip() {
for cn in [
CostNumber::PerUnit { value: dec!(42) },
CostNumber::Total { value: dec!(420) },
CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
] {
let json = serde_json::to_string(&cn).unwrap();
let back: CostNumber = serde_json::from_str(&json).unwrap();
assert_eq!(cn, back, "round trip lost data for {cn:?}");
}
}
#[cfg(feature = "rkyv")]
#[test]
fn cost_number_rkyv_round_trip_preserves_all_variants() {
for cn in [
CostNumber::PerUnit { value: dec!(150) },
CostNumber::Total { value: dec!(1500) },
CostNumber::PerUnitFromTotal(BookedCost::new(dec!(30), dec!(300), dec!(10))),
] {
let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&cn).unwrap();
let back: CostNumber =
rkyv::from_bytes::<CostNumber, rkyv::rancor::Error>(&bytes).unwrap();
assert_eq!(cn, back, "rkyv round-trip lost data for variant {cn:?}");
}
}
#[cfg(feature = "rkyv")]
#[test]
fn cost_number_archived_bytes_snapshot() {
let per_unit = CostNumber::PerUnit { value: dec!(150) };
let per_unit_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&per_unit).unwrap();
assert!(
!per_unit_bytes.is_empty(),
"PerUnit must serialize to non-empty bytes"
);
let total = CostNumber::Total { value: dec!(1500) };
let total_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&total).unwrap();
assert!(!total_bytes.is_empty());
let pu_same = CostNumber::PerUnit { value: dec!(1500) };
let pu_same_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&pu_same).unwrap();
assert_ne!(
total_bytes.as_ref(),
pu_same_bytes.as_ref(),
"PerUnit and Total of the same value must serialize distinctly"
);
let booked = CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2)));
let booked_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&booked).unwrap();
let pu_only = CostNumber::PerUnit { value: dec!(150) };
let pu_only_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&pu_only).unwrap();
assert_ne!(
booked_bytes.as_ref(),
pu_only_bytes.as_ref(),
"PerUnitFromTotal and PerUnit must serialize distinctly (preserved total is load-bearing)"
);
}
#[test]
fn cost_spec_display_renders_per_unit_from_total_as_per_unit() {
let b = BookedCost::new(dec!(150), dec!(300), dec!(2));
let spec = CostSpec::empty()
.with_number(CostNumber::PerUnitFromTotal(b))
.with_currency("USD");
let s = format!("{spec}");
assert!(s.contains("150"), "expected per-unit 150 in {s}");
assert!(!s.contains("# 300"), "must NOT render as `# total` ({s})");
}
}