Functional Weave
Code in TypeScript

retail.shipping-rate@1.0.0

impl/rust.rs

5,728 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_compare::compare_money;  ← from money.compare ^1.0.0 · built alongside by fune
use super::retail_volumetric_weight::chargeable_weight;  ← from retail.volumetric-weight ^1.0.0 · built alongside by fune

fn is_iso_date(value: &str) -> bool {
    let bytes = value.as_bytes();
    bytes.len() == 10
        && bytes[4] == b'-'
        && bytes[7] == b'-'
        && bytes
            .iter()
            .enumerate()
            .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}

fn chargeable_in(band: &ShippingBand, parcel: &Parcel) -> i64 {
    match band.volumetric_divisor {
        None => round_div(parcel.actual_grams, band.round_up_to_grams, "up") * band.round_up_to_grams,
        Some(divisor) => {
            chargeable_weight(
                parcel.length_mm,
                parcel.width_mm,
                parcel.height_mm,
                parcel.actual_grams,
                divisor,
                band.round_up_to_grams,
            )
            .chargeable_grams
        }
    }
}

/// The delivery charge for a parcel: the smallest band on the rate card in
/// force on the order date that takes it, free when the basket reaches the
/// band's threshold.
///
/// # Panics
/// Panics on a malformed date or parcel, mixed currencies, or when no band
/// takes the parcel.
pub fn shipping_rate(
    bands: &[ShippingBand],
    service: &str,
    zone: &str,
    parcel: &Parcel,
    basket_total: &Money,
    on_date: &str,
) -> ShippingQuote {
    if !is_iso_date(on_date) {
        panic!("onDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date);
    }
    for d in [parcel.length_mm, parcel.width_mm, parcel.height_mm] {
        if d < 1 {
            panic!("dimensions must be 1 mm or more, received {}", d);
        }
    }
    if parcel.actual_grams < 0 {
        panic!("actualGrams must not be negative, received {}", parcel.actual_grams);
    }
    let longest = parcel.length_mm.max(parcel.width_mm).max(parcel.height_mm);

    let mut best: Option<&ShippingBand> = None;
    let mut best_grams = 0;
    for band in bands {
        if band.service != service || band.zone != zone {
            continue;
        }
        if on_date < band.valid_from.as_str() {
            continue;
        }
        if let Some(to) = &band.valid_to {
            if on_date > to.as_str() {
                continue;
            }
        }
        if let Some(max_length) = band.max_length_mm {
            if longest > max_length {
                continue;
            }
        }
        let grams = chargeable_in(band, parcel);
        if grams > band.max_grams {
            continue;
        }
        let better = match best {
            None => true,
            Some(b) => {
                band.max_grams < b.max_grams
                    || (band.max_grams == b.max_grams && compare_money(&band.price, &b.price) < 0)
            }
        };
        if better {
            best = Some(band);
            best_grams = grams;
        }
    }
    let best = match best {
        Some(b) => b,
        None => panic!(
            "no shipping band for service \"{}\" to zone \"{}\" on {} fits this parcel",
            service, zone, on_date
        ),
    };

    let free = match &best.free_over {
        Some(threshold) => compare_money(basket_total, threshold) >= 0,
        None => false,
    };
    ShippingQuote {
        service: service.to_string(),
        zone: zone.to_string(),
        chargeable_grams: best_grams,
        band_max_grams: best.max_grams,
        standard_price: best.price.clone(),
        price: if free { money(0, &best.price.currency) } else { best.price.clone() },
        free,
    }
}

fn opt_int(v: &Value) -> Option<i64> {
    if v.is_null() {
        None
    } else {
        Some(v.as_i64())
    }
}

pub fn shipping_band_from_value(v: &Value) -> ShippingBand {
    ShippingBand {
        service: v.get("service").as_str().to_string(),
        zone: v.get("zone").as_str().to_string(),
        max_grams: v.get("maxGrams").as_i64(),
        max_length_mm: opt_int(v.get("maxLengthMm")),
        volumetric_divisor: opt_int(v.get("volumetricDivisor")),
        round_up_to_grams: v.get("roundUpToGrams").as_i64(),
        price: money_from_value(v.get("price")),
        free_over: if v.get("freeOver").is_null() {
            None
        } else {
            Some(money_from_value(v.get("freeOver")))
        },
        valid_from: v.get("validFrom").as_str().to_string(),
        valid_to: if v.get("validTo").is_null() {
            None
        } else {
            Some(v.get("validTo").as_str().to_string())
        },
    }
}

pub fn parcel_from_value(v: &Value) -> Parcel {
    Parcel {
        length_mm: v.get("lengthMm").as_i64(),
        width_mm: v.get("widthMm").as_i64(),
        height_mm: v.get("heightMm").as_i64(),
        actual_grams: v.get("actualGrams").as_i64(),
    }
}

pub fn shipping_quote_to_value(q: &ShippingQuote) -> Value {
    Value::obj(vec![
        ("service", Value::str(&q.service)),
        ("zone", Value::str(&q.zone)),
        ("chargeableGrams", Value::Int(q.chargeable_grams)),
        ("bandMaxGrams", Value::Int(q.band_max_grams)),
        ("standardPrice", money_to_value(&q.standard_price)),
        ("price", money_to_value(&q.price)),
        ("free", Value::Bool(q.free)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let bands: Vec<ShippingBand> = args[0].as_arr().iter().map(shipping_band_from_value).collect();
    shipping_quote_to_value(&shipping_rate(
        &bands,
        args[1].as_str(),
        args[2].as_str(),
        &parcel_from_value(&args[3]),
        &money_from_value(&args[4]),
        args[5].as_str(),
    ))
}