versa_semval 0.7.2

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

pub fn subtotal_should_equal_sum_of_line_items(
  data: &serde_json::Value,
) -> Option<ViolationDetails> {
  let subtotal = data
    .get("header")
    .and_then(|header| header.get("subtotal"))
    .and_then(|subtotal| subtotal.as_i64())
    .unwrap_or(0);

  let itemization = data.get("itemization")?;

  let mut line_items_total = 0i64;

  // Check general itemization
  if let Some(general) = itemization.get("general") {
    if let Some(items) = general.get("items").and_then(|i| i.as_array()) {
      for item in items {
        if let Some(amount) = item.get("amount").and_then(|a| a.as_i64()) {
          line_items_total += amount;
        }
      }
    }
  }

  // Check ecommerce itemization
  if let Some(ecommerce) = itemization.get("ecommerce") {
    if let Some(shipments) = ecommerce.get("shipments").and_then(|s| s.as_array()) {
      for shipment in shipments {
        if let Some(items) = shipment.get("items").and_then(|i| i.as_array()) {
          for item in items {
            if let Some(amount) = item.get("amount").and_then(|a| a.as_i64()) {
              line_items_total += amount;
            }
          }
        }
      }
    }
    // Also check invoice_level_line_items
    if let Some(invoice_items) = ecommerce
      .get("invoice_level_line_items")
      .and_then(|i| i.as_array())
    {
      for item in invoice_items {
        if let Some(amount) = item.get("amount").and_then(|a| a.as_i64()) {
          line_items_total += amount;
        }
      }
    }
  }

  // Check lodging itemization
  if let Some(lodging) = itemization.get("lodging") {
    if let Some(items) = lodging.get("items").and_then(|i| i.as_array()) {
      for item in items {
        if let Some(amount) = item.get("amount").and_then(|a| a.as_i64()) {
          line_items_total += amount;
        }
      }
    }
  }

  // Check car rental itemization
  if let Some(car_rental) = itemization.get("car_rental") {
    if let Some(items) = car_rental.get("items").and_then(|i| i.as_array()) {
      for item in items {
        if let Some(amount) = item.get("amount").and_then(|a| a.as_i64()) {
          line_items_total += amount;
        }
      }
    }
  }

  // Check subscription itemization
  if let Some(subscription) = itemization.get("subscription") {
    // Check for 'items' first (standard format)
    if let Some(items) = subscription.get("items").and_then(|i| i.as_array()) {
      for item in items {
        if let Some(total) = item.get("total").and_then(|t| t.as_i64()) {
          line_items_total += total;
        }
      }
    }
    // Also check for 'subscription_items' (alternative format)
    else if let Some(items) = subscription
      .get("subscription_items")
      .and_then(|i| i.as_array())
    {
      for item in items {
        // For subscription items, use 'amount' field instead of 'total'
        if let Some(amount) = item.get("amount").and_then(|a| a.as_i64()) {
          line_items_total += amount;
        }
      }
    }
  }

  // Check flight itemization (tickets have subtotals or fare, or segments have fare)
  if let Some(flight) = itemization.get("flight") {
    if let Some(tickets) = flight.get("tickets").and_then(|t| t.as_array()) {
      for ticket in tickets {
        // Check for subtotal first, then fall back to fare at ticket level
        if let Some(subtotal) = ticket.get("subtotal").and_then(|s| s.as_i64()) {
          line_items_total += subtotal;
        } else if let Some(fare) = ticket.get("fare").and_then(|f| f.as_i64()) {
          line_items_total += fare;
        } else {
          // If no ticket-level fare/subtotal, sum segment fares
          if let Some(segments) = ticket.get("segments").and_then(|s| s.as_array()) {
            for segment in segments {
              if let Some(segment_fare) = segment.get("fare").and_then(|f| f.as_i64()) {
                line_items_total += segment_fare;
              }
            }
          }
        }
      }
    }
  }

  // Check transit route itemization
  if let Some(transit_route) = itemization.get("transit_route") {
    if let Some(items) = transit_route
      .get("transit_route_items")
      .and_then(|i| i.as_array())
    {
      for item in items {
        if let Some(fare) = item.get("fare").and_then(|f| f.as_i64()) {
          line_items_total += fare;
        }
      }
    }
  }

  if subtotal != line_items_total {
    return Some(ViolationDetails {
      details: Some(format!(
        "subtotal={}; sum_of_line_items={}",
        subtotal, line_items_total
      )),
    });
  }

  None
}

#[cfg(test)]
mod tests {
  use super::*;
  use pretty_assertions::assert_eq;

  #[test]
  fn test_subtotal_equals_sum_of_general_items() {
    let valid_receipt = serde_json::json!({
      "header": { "subtotal": 300 },
      "itemization": {
        "general": {
          "items": [
            { "amount": 100 },
            { "amount": 150 },
            { "amount": 50 }
          ]
        }
      }
    });

    let result = subtotal_should_equal_sum_of_line_items(&valid_receipt);
    assert!(result.is_none());
  }

  #[test]
  fn test_subtotal_not_equals_sum_of_items() {
    let invalid_receipt = serde_json::json!({
      "header": { "subtotal": 400 },
      "itemization": {
        "general": {
          "items": [
            { "amount": 100 },
            { "amount": 150 },
            { "amount": 50 }
          ]
        }
      }
    });

    let result = subtotal_should_equal_sum_of_line_items(&invalid_receipt);
    assert!(result.is_some());
    let violation = result.unwrap();
    assert_eq!(
      violation.details,
      Some("subtotal=400; sum_of_line_items=300".to_string())
    );
  }

  #[test]
  fn test_subtotal_with_multiple_itemization_types() {
    let valid_receipt = serde_json::json!({
      "header": { "subtotal": 600 },
      "itemization": {
        "general": {
          "items": [
            { "amount": 100 },
            { "amount": 150 }
          ]
        },
        "ecommerce": {
          "shipments": [
            {
              "items": [
                { "amount": 200 },
                { "amount": 150 }
              ]
            }
          ]
        }
      }
    });

    let result = subtotal_should_equal_sum_of_line_items(&valid_receipt);
    assert!(result.is_none());
  }

  #[test]
  fn test_subtotal_with_flight_ticket_fare() {
    let valid_receipt = serde_json::json!({
      "header": { "subtotal": 1000 },
      "itemization": {
        "flight": {
          "tickets": [
            { "fare": 600 },
            { "fare": 400 }
          ]
        }
      }
    });

    let result = subtotal_should_equal_sum_of_line_items(&valid_receipt);
    assert!(result.is_none());
  }

  #[test]
  fn test_subtotal_with_segment_fares() {
    let valid_receipt = serde_json::json!({
      "header": { "subtotal": 1200 },
      "itemization": {
        "flight": {
          "tickets": [
            {
              "segments": [
                { "fare": 300 },
                { "fare": 300 }
              ]
            },
            {
              "segments": [
                { "fare": 300 },
                { "fare": 300 }
              ]
            }
          ]
        }
      }
    });

    let result = subtotal_should_equal_sum_of_line_items(&valid_receipt);
    assert!(result.is_none());
  }

  #[test]
  fn test_subtotal_with_subscription_items() {
    let valid_receipt = serde_json::json!({
      "header": { "subtotal": 1500 },
      "itemization": {
        "subscription": {
          "subscription_items": [
            { "amount": 1000 },
            { "amount": 500 }
          ]
        }
      }
    });

    let result = subtotal_should_equal_sum_of_line_items(&valid_receipt);
    assert!(result.is_none());
  }

  #[test]
  fn test_subtotal_with_missing_itemization() {
    let receipt = serde_json::json!({
      "header": { "subtotal": 100 }
    });

    let result = subtotal_should_equal_sum_of_line_items(&receipt);
    assert!(result.is_none()); // No itemization means no validation
  }

  #[test]
  fn test_subtotal_with_empty_itemization() {
    let receipt = serde_json::json!({
      "header": { "subtotal": 0 },
      "itemization": {}
    });

    let result = subtotal_should_equal_sum_of_line_items(&receipt);
    assert!(result.is_none()); // 0 == 0, so valid
  }

  #[test]
  fn test_subtotal_mismatch_with_empty_itemization() {
    let receipt = serde_json::json!({
      "header": { "subtotal": 100 },
      "itemization": {}
    });

    let result = subtotal_should_equal_sum_of_line_items(&receipt);
    assert!(result.is_some());
    let violation = result.unwrap();
    assert_eq!(
      violation.details,
      Some("subtotal=100; sum_of_line_items=0".to_string())
    );
  }
}