Functional Weave
Code in Rust

retail.coupon-validate@1.0.0

impl/rust.rs

4,960 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::money_amount::{money_from_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::money_sum::sum_money;  ← from money.sum ^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 is_eligible(coupon: &Coupon, line: &CouponLine) -> bool {
    let open = coupon.skus.is_empty() && coupon.categories.is_empty();
    let listed = open || coupon.skus.contains(&line.sku) || coupon.categories.contains(&line.category);
    let excluded = coupon.excluded_skus.contains(&line.sku)
        || coupon.excluded_categories.contains(&line.category);
    listed && !excluded
}

/// Whether a coupon can be used on a basket, with every reason it cannot.
/// ISO dates compare correctly as strings.
///
/// # Panics
/// Panics on a malformed date, negative customer uses, or mixed currencies.
pub fn validate_coupon(coupon: &Coupon, lines: &[CouponLine], customer_uses: i64, on_date: &str) -> CouponCheck {
    if !is_iso_date(on_date) {
        panic!("onDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date);
    }
    if customer_uses < 0 {
        panic!("customerUses must not be negative, received {}", customer_uses);
    }

    let eligible_lines: Vec<i64> = lines
        .iter()
        .enumerate()
        .filter(|(_, line)| is_eligible(coupon, line))
        .map(|(i, _)| i as i64)
        .collect();

    let mut reasons: Vec<String> = Vec::new();
    if let Some(from) = &coupon.valid_from {
        if on_date < from.as_str() {
            reasons.push("not-yet-valid".to_string());
        }
    }
    if let Some(to) = &coupon.valid_to {
        if on_date > to.as_str() {
            reasons.push("expired".to_string());
        }
    }
    if let Some(max) = coupon.max_uses {
        if coupon.times_used >= max {
            reasons.push("usage-limit-reached".to_string());
        }
    }
    if let Some(max) = coupon.max_uses_per_customer {
        if customer_uses >= max {
            reasons.push("customer-limit-reached".to_string());
        }
    }
    if eligible_lines.is_empty() {
        reasons.push("no-eligible-items".to_string());
    }
    if let Some(minimum) = &coupon.minimum_spend {
        let counted: Vec<Money> = if coupon.minimum_spend_on == "eligible" {
            eligible_lines.iter().map(|i| lines[*i as usize].total.clone()).collect()
        } else {
            lines.iter().map(|l| l.total.clone()).collect()
        };
        let spend = sum_money(&counted, &minimum.currency);
        if compare_money(&spend, minimum) < 0 {
            reasons.push("minimum-spend-not-met".to_string());
        }
    }

    CouponCheck {
        valid: reasons.is_empty(),
        reasons,
        eligible_lines,
    }
}

fn strings(v: &Value) -> Vec<String> {
    v.as_arr().iter().map(|s| s.as_str().to_string()).collect()
}

fn opt_str(v: &Value) -> Option<String> {
    if v.is_null() {
        None
    } else {
        Some(v.as_str().to_string())
    }
}

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

pub fn coupon_from_value(v: &Value) -> Coupon {
    Coupon {
        code: v.get("code").as_str().to_string(),
        valid_from: opt_str(v.get("validFrom")),
        valid_to: opt_str(v.get("validTo")),
        minimum_spend: if v.get("minimumSpend").is_null() {
            None
        } else {
            Some(money_from_value(v.get("minimumSpend")))
        },
        minimum_spend_on: v.get("minimumSpendOn").as_str().to_string(),
        max_uses: opt_int(v.get("maxUses")),
        times_used: v.get("timesUsed").as_i64(),
        max_uses_per_customer: opt_int(v.get("maxUsesPerCustomer")),
        skus: strings(v.get("skus")),
        categories: strings(v.get("categories")),
        excluded_skus: strings(v.get("excludedSkus")),
        excluded_categories: strings(v.get("excludedCategories")),
    }
}

pub fn coupon_line_from_value(v: &Value) -> CouponLine {
    CouponLine {
        sku: v.get("sku").as_str().to_string(),
        category: v.get("category").as_str().to_string(),
        total: money_from_value(v.get("total")),
    }
}

pub fn coupon_check_to_value(c: &CouponCheck) -> Value {
    Value::obj(vec![
        ("valid", Value::Bool(c.valid)),
        ("reasons", Value::Arr(c.reasons.iter().map(|r| Value::str(r)).collect())),
        ("eligibleLines", Value::Arr(c.eligible_lines.iter().map(|i| Value::Int(*i)).collect())),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let lines: Vec<CouponLine> = args[1].as_arr().iter().map(coupon_line_from_value).collect();
    coupon_check_to_value(&validate_coupon(
        &coupon_from_value(&args[0]),
        &lines,
        args[2].as_i64(),
        args[3].as_str(),
    ))
}