use super::funejson::Value; use super::dates_add_days::parse_iso_date; use super::money_amount::{money, money_from_value, money_to_value, Money}; /// The charging period a date falls in, as (year, month) of its start. With /// `cycle_day` 15, 2026-03-14 belongs to the period that began on 2026-02-15. fn period_key(iso: &str, cycle_day: i64) -> (i64, i64) { let d = parse_iso_date(iso); let (mut year, mut month) = (d.year, d.month); if d.day < cycle_day { month -= 1; if month == 0 { month = 12; year -= 1; } } (year, month) } /// Apply a monthly cap to a list of charges. /// /// Within each charging period the charges are taken in date order (ties in /// input order) until the cap is used up: the charge that crosses the cap is /// reduced to what is left, and every later charge in that period is waived in /// full. The result keeps the input order. /// /// # Panics /// Panics on a cycle day outside 1-28, a negative cap or charge, a currency /// mismatch, or a malformed date. pub fn apply_fee_cap(charges: &[FeeCharge], monthly_cap: &Money, cycle_day: i64) -> FeeCapResult { if !(1..=28).contains(&cycle_day) { panic!("cycleDay must be 1 to 28, received {}", cycle_day); } if monthly_cap.minor < 0 { panic!("monthlyCap must not be negative, received {}", monthly_cap.minor); } let currency = monthly_cap.currency.as_str(); let mut keys: Vec<(i64, i64)> = Vec::new(); for charge in charges { if charge.amount.currency != currency { panic!("currency mismatch: {} and {}", charge.amount.currency, currency); } if charge.amount.minor < 0 { panic!("charge \"{}\" must not be negative, received {}", charge.label, charge.amount.minor); } keys.push(period_key(&charge.date, cycle_day)); } // sort_by is stable, so equal dates keep their input order. let mut order: Vec = (0..charges.len()).collect(); order.sort_by(|a, b| charges[*a].date.cmp(&charges[*b].date)); let mut used: Vec<((i64, i64), i64)> = Vec::new(); let mut charged_minor = vec![0i64; charges.len()]; for index in order { let slot = match used.iter().position(|(k, _)| *k == keys[index]) { Some(p) => p, None => { used.push((keys[index], 0)); used.len() - 1 } }; let spent = used[slot].1; let take = charges[index].amount.minor.min(monthly_cap.minor - spent); charged_minor[index] = take; used[slot].1 = spent + take; } let mut out = Vec::new(); let mut total_charged = 0i64; let mut total_waived = 0i64; for (index, charge) in charges.iter().enumerate() { let charged = charged_minor[index]; let waived = charge.amount.minor - charged; total_charged += charged; total_waived += waived; out.push(CappedCharge { date: charge.date.clone(), label: charge.label.clone(), amount: money(charge.amount.minor, currency), charged: money(charged, currency), waived: money(waived, currency), }); } FeeCapResult { charges: out, total_charged: money(total_charged, currency), total_waived: money(total_waived, currency), } } pub fn fee_charge_from_value(v: &Value) -> FeeCharge { FeeCharge { date: v.get("date").as_str().to_string(), label: v.get("label").as_str().to_string(), amount: money_from_value(v.get("amount")), } } pub fn fee_cap_result_to_value(result: &FeeCapResult) -> Value { Value::obj(vec![ ( "charges", Value::Arr( result .charges .iter() .map(|c| { Value::obj(vec![ ("date", Value::str(&c.date)), ("label", Value::str(&c.label)), ("amount", money_to_value(&c.amount)), ("charged", money_to_value(&c.charged)), ("waived", money_to_value(&c.waived)), ]) }) .collect(), ), ), ("totalCharged", money_to_value(&result.total_charged)), ("totalWaived", money_to_value(&result.total_waived)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let charges: Vec = args[0].as_arr().iter().map(fee_charge_from_value).collect(); fee_cap_result_to_value(&apply_fee_cap(&charges, &money_from_value(&args[1]), args[2].as_i64())) }