use super::funejson::Value; use super::finance_invoice_calculate::{calculate_invoice, invoice_line_from_value, invoice_to_value, Invoice, InvoiceLine}; use super::finance_invoice_line_total::line_total; use super::money_allocate::allocate; use super::money_amount::{assert_same_currency, money, money_from_value}; use super::money_apply_rate::apply_rate; fn check_discount(d: &Discount, currency: &str) { if d.basis_points.is_none() == d.amount_off.is_none() { panic!( "a discount needs exactly one of basisPoints and amountOff: \"{}\"", d.description ); } if let Some(bp) = d.basis_points { if !(0..=10000).contains(&bp) { panic!("discount basis points must be between 0 and 10000, received {}", bp); } } if let Some(off) = &d.amount_off { assert_same_currency(&money(0, currency), off); if off.minor < 0 { panic!("discount amount must not be negative, received {}", off.minor); } } if d.start_period < 1 { panic!("discount start period must be 1 or more, received {}", d.start_period); } match d.duration.as_str() { "repeating" => { if d.duration_periods.map_or(true, |n| n < 1) { panic!( "a repeating discount needs durationPeriods of 1 or more, received {:?}", d.duration_periods ); } } "once" | "forever" => { if d.duration_periods.is_some() { panic!( "durationPeriods applies only to a repeating discount: \"{}\"", d.description ); } } other => panic!( "unknown discount duration \"{}\": expected once, repeating or forever", other ), } } fn applies(d: &Discount, period: i64) -> bool { match d.duration.as_str() { "once" => period == d.start_period, "repeating" => period >= d.start_period && period < d.start_period + d.duration_periods.unwrap_or(0), _ => period >= d.start_period, } } /// The renewal invoice for one billing period: plan, add-ons and usage, less /// the coupons in force in that period, taxed per line by /// finance::invoice::calculate. Each coupon becomes negative lines, one per /// VAT category, so the reduction is taxed at the rate of what it reduces. /// /// # Panics /// Panics on a period below 1, a malformed coupon, or anything /// `calculate_invoice` refuses. pub fn renewal_invoice( plan: &InvoiceLine, add_ons: &[InvoiceLine], usage: &[InvoiceLine], discounts: &[Discount], period_number: i64, jurisdiction: &str, invoice_date: &str, ) -> Invoice { if period_number < 1 { panic!("period number must be 1 or more, received {}", period_number); } let currency = plan.unit_price.currency.clone(); let mut items: Vec = vec![plan.clone()]; items.extend(add_ons.iter().cloned()); items.extend(usage.iter().cloned()); let mut categories: Vec = Vec::new(); let mut category_net: Vec = Vec::new(); let mut running: i64 = 0; for item in &items { let net = line_total(&item.unit_price, item.quantity, item.discount_basis_points); assert_same_currency(&money(0, ¤cy), &net); let k = match categories.iter().position(|c| *c == item.tax_category) { Some(k) => k, None => { categories.push(item.tax_category.clone()); category_net.push(0); categories.len() - 1 } }; category_net[k] += net.minor; running += net.minor; } // Credit-only categories take no share of a discount. let weights: Vec = category_net.iter().map(|&n| n.max(0)).collect(); let mut discount_lines: Vec = Vec::new(); for d in discounts { check_discount(d, ¤cy); if !applies(d, period_number) || running <= 0 { continue; } let amount = match (d.basis_points, &d.amount_off) { (Some(bp), _) => apply_rate(&money(running, ¤cy), bp, "half-up").minor, (None, Some(off)) => off.minor.min(running), (None, None) => 0, }; if amount == 0 { continue; } running -= amount; for (k, share) in allocate(&money(amount, ¤cy), &weights).iter().enumerate() { if share.minor != 0 { discount_lines.push(InvoiceLine { description: d.description.clone(), unit_price: money(-share.minor, ¤cy), quantity: 1, discount_basis_points: 0, tax_category: categories[k].clone(), }); } } } items.extend(discount_lines); calculate_invoice(&items, jurisdiction, invoice_date) } pub fn discount_from_value(v: &Value) -> Discount { Discount { description: v.get("description").as_str().to_string(), basis_points: if v.get("basisPoints").is_null() { None } else { Some(v.get("basisPoints").as_i64()) }, amount_off: if v.get("amountOff").is_null() { None } else { Some(money_from_value(v.get("amountOff"))) }, duration: v.get("duration").as_str().to_string(), duration_periods: if v.get("durationPeriods").is_null() { None } else { Some(v.get("durationPeriods").as_i64()) }, start_period: v.get("startPeriod").as_i64(), } } pub fn fune_vector(args: &[Value]) -> Value { let add_ons: Vec = args[1].as_arr().iter().map(invoice_line_from_value).collect(); let usage: Vec = args[2].as_arr().iter().map(invoice_line_from_value).collect(); let discounts: Vec = args[3].as_arr().iter().map(discount_from_value).collect(); invoice_to_value(&renewal_invoice( &invoice_line_from_value(&args[0]), &add_ons, &usage, &discounts, args[4].as_i64(), args[5].as_str(), args[6].as_str(), )) }