versa_semval 0.5.4

Cross-platform module for semantic validation of Versa data
Documentation
use crate::model::ViolationDetails;

pub struct Rule<'a, F>
where
  F: Fn(&'a serde_json::Value) -> Option<ViolationDetails>,
{
  pub name: &'a str,
  pub description: &'a str,
  pub evaluate: F,
}

impl<'a, F> Rule<'a, F>
where
  F: Fn(&'a serde_json::Value) -> Option<ViolationDetails>,
{
  fn new(name: &'a str, description: &'a str, evaluate: F) -> Self {
    Self {
      name,
      description,
      evaluate,
    }
  }
}

pub fn rules<'a>() -> Vec<Rule<'a, fn(&'a serde_json::Value) -> Option<ViolationDetails>>> {
  vec![
    Rule::new(
      "header-paid-should-be-sum-of-payments",
      "The sum of all payments should always equal the amount in the header's 'paid' field.",
      header_paid_should_be_sum_of_payments,
    ),
    Rule::new(
      "all-headers-should-have-tz",
      "All receipts should include a timezone that best represents either the customer's physical location at the time of purchase, or the merchant's region of business.",
      all_headers_should_have_tz
    ),
    Rule::new(
      "flight_fare-at-ticket-or-segment-exclusively",
      "Flight receipts should set the fare at the ticket level, or the segment level, but not both.",
      flight_fare_at_ticket_or_segment_exclusively,
    ),
    Rule::new(
      "header-total-should-be-sum-of-itemization",
      "The header total should equal the sum of all line items, taxes, and adjustments.",
      header_total_should_be_sum_of_all_line_items_taxes_and_adjustments,
    ),
    Rule::new(
      "schema-version-should-be-current",
      "The schema should be updated to the latest version.",
      crate::schema_version::schema_version_should_be_current,
    )
  ]
}

fn header_paid_should_be_sum_of_payments<'a>(
  data: &'a serde_json::Value,
) -> Option<ViolationDetails> {
  let payments_array = data.get("payments").and_then(|val| val.as_array());

  let paid = data
    .get("header")
    .and_then(|header| header.get("paid"))
    .and_then(|paid| paid.as_i64());

  if let Some(paid) = paid {
    let mut payment_subtotal = 0;
    if let Some(payments) = payments_array {
      for payment in payments {
        payment_subtotal += payment
          .get("amount")
          .and_then(|amount| amount.as_i64())
          .unwrap_or(0);
      }
    }
    if payment_subtotal != paid {
      return Some(ViolationDetails {
        details: Some(format!(
          "paid={}; sum_of_payments={}",
          paid, payment_subtotal
        )),
      });
    }
    return None;
  }
  return Some(ViolationDetails { details: None });
}

// paid should always equal sum of payments

fn all_headers_should_have_tz<'a>(data: &'a serde_json::Value) -> Option<ViolationDetails> {
  let timezone = data
    .get("header")
    .and_then(|header| header.get("location"))
    .and_then(|location| location.get("address"))
    .and_then(|address| address.get("tz"));

  if let Some(tz) = timezone {
    if (!tz.is_null()) && tz.is_string() {
      return None;
    }
  }
  return Some(ViolationDetails { details: None });
}

// Do flight tickets have either ticket level or segment level fare data, but not both

fn flight_fare_at_ticket_or_segment_exclusively<'a>(
  data: &'a serde_json::Value,
) -> Option<ViolationDetails> {
  let mut fare_on_ticket = false;
  let mut fare_on_segment = false;

  let tickets = data
    .get("itemization")
    .and_then(|itemization| itemization.get("flight"))
    .and_then(|flight| flight.get("tickets"))
    .and_then(|tickets| tickets.as_array());

  if let Some(tickets) = tickets {
    for ticket in tickets {
      if let Some(fare) = ticket.get("fare") {
        if !fare.is_null() {
          fare_on_ticket = true;
        }
      }
      if let Some(segments) = ticket.get("segments").and_then(|s| s.as_array()) {
        for segment in segments {
          if let Some(fare) = segment.get("fare") {
            if !fare.is_null() {
              fare_on_segment = true;
            }
          }
        }
      }
    }
  }
  if fare_on_ticket && fare_on_segment {
    return Some(ViolationDetails { details: None });
  }

  None
}

// Does the header total equal the sum of all line items + taxes + adjustments

fn get_i64(value: &serde_json::Value, key: &str) -> i64 {
  value.get(key).and_then(|v| v.as_i64()).unwrap_or(0)
}

// TODO: This needs work
fn header_total_should_be_sum_of_all_line_items_taxes_and_adjustments(
  data: &serde_json::Value,
) -> Option<ViolationDetails> {
  let mut line_items_total = 0;
  let mut taxes_total = 0;
  let mut adjustments_total = 0;
  let header_total = data
    .get("header")
    .and_then(|header| header.get("total"))
    .and_then(|total| total.as_i64())
    .unwrap_or(0);

  let flight = data
    .get("itemization")
    .and_then(|itemization| itemization.get("flight"))
    .and_then(|flight| if flight.is_null() { None } else { Some(flight) });

  if flight.is_none() {
    return None;
  }

  // WARN: Right now only implemented for flight itemizations
  let tickets = data
    .get("itemization")
    .and_then(|itemization| itemization.get("flight"))
    .and_then(|flight| flight.get("tickets"))
    .and_then(|tickets| tickets.as_array());

  if let Some(tickets) = tickets {
    for ticket in tickets {
      line_items_total += get_i64(&ticket, "fare");
      if let Some(taxes) = ticket.get("taxes").and_then(|taxes| taxes.as_array()) {
        for tax in taxes {
          taxes_total += get_i64(tax, "amount");
        }
      }
      if let Some(segments) = ticket.get("segments").and_then(|s| s.as_array()) {
        for segment in segments {
          line_items_total += get_i64(segment, "fare");
          if let Some(adjustments) = segment
            .get("adjustments")
            .and_then(|adjustments| adjustments.as_array())
          {
            for adjustment in adjustments {
              adjustments_total += get_i64(adjustment, "amount");
            }
          }
          if let Some(taxes) = segment.get("taxes").and_then(|taxes| taxes.as_array()) {
            for tax in taxes {
              taxes_total += get_i64(tax, "amount");
            }
          }
        }
      }
    }
  }

  if let Some(adjustments) = data
    .get("itemization")
    .and_then(|itemization| itemization.get("flight"))
    .and_then(|flight| flight.get("invoice_level_adjustments"))
    .and_then(|adjustments| adjustments.as_array())
  {
    for adjustment in adjustments {
      adjustments_total += get_i64(adjustment, "amount");
    }
  }

  if line_items_total + taxes_total + adjustments_total == header_total {
    None
  } else {
    Some(ViolationDetails {
      details: Some(format!(
        "line_items_total={}; taxes_total={}; adjustments_total={}; header_total={}",
        line_items_total, taxes_total, adjustments_total, header_total
      )),
    })
  }
}