defmodule Validator do @moduledoc """ Validates ISO8583 transaction responses against expected values. """ alias ISO8583.Parser @doc """ Validates a parsed response against expected test case values. ## Returns Validation map with: - overall_status: :pass | :fail - checks: List of individual validation checks - failures: List of failure messages """ def validate(test_case, parsed_response) do checks = [] failures = [] # Validation 1: MTI Response {checks, failures} = validate_mti( test_case.expected_mti_response, parsed_response.mti, checks, failures ) # Validation 2: Response Code (DE39) actual_rc = Parser.get_response_code(parsed_response) {checks, failures} = validate_response_code( test_case.expected_response_code, actual_rc, checks, failures ) # Validation 3: STAN should match request actual_stan = Parser.get_stan(parsed_response) {checks, failures} = validate_stan( test_case.stan, actual_stan, checks, failures ) # Validation 4: Additional field validations based on transaction type {checks, failures} = validate_transaction_specific( test_case, parsed_response, checks, failures ) # Validation 5: Custom validation rules if specified {checks, failures} = validate_custom_rules( test_case, parsed_response, checks, failures ) overall_status = if failures == [], do: :pass, else: :fail %{ overall_status: overall_status, checks: Enum.reverse(checks), failures: Enum.reverse(failures), total_checks: length(checks), failed_checks: length(failures) } end # Individual validation functions defp validate_mti(expected, actual, checks, failures) do check = %{ name: "MTI Response", expected: expected, actual: actual, status: if(expected == actual, do: :pass, else: :fail) } checks = [check | checks] failures = if check.status == :fail do ["MTI mismatch: expected #{expected}, got #{actual}" | failures] else failures end {checks, failures} end defp validate_response_code(expected, actual, checks, failures) do check = %{ name: "Response Code (DE39)", expected: expected, actual: actual, status: if(expected == actual, do: :pass, else: :fail) } checks = [check | checks] failures = if check.status == :fail do ["Response code mismatch: expected #{expected}, got #{actual}" | failures] else failures end {checks, failures} end defp validate_stan(expected, actual, checks, failures) do # Normalize STAN for comparison (remove leading zeros) expected_normalized = expected |> String.trim_leading("0") actual_normalized = if actual, do: actual |> String.trim_leading("0"), else: nil check = %{ name: "STAN (DE11)", expected: expected, actual: actual, status: if(expected_normalized == actual_normalized, do: :pass, else: :fail) } checks = [check | checks] failures = if check.status == :fail do ["STAN mismatch: expected #{expected}, got #{actual}" | failures] else failures end {checks, failures} end defp validate_transaction_specific(test_case, parsed_response, checks, failures) do scenario = Map.get(test_case, :scenario, "") cond do # For approved transactions, check for approval code (DE38) String.contains?(scenario, "approved") -> validate_approval_code(parsed_response, checks, failures) # For declined transactions, ensure no approval code String.contains?(scenario, "declined") -> validate_no_approval_code(parsed_response, checks, failures) # Default: no additional checks true -> {checks, failures} end end defp validate_approval_code(parsed_response, checks, failures) do approval_code = Parser.get_approval_code(parsed_response) check = %{ name: "Approval Code Present (DE38)", expected: "Should be present for approved transactions", actual: approval_code, status: if(approval_code && String.trim(approval_code) != "", do: :pass, else: :fail) } checks = [check | checks] failures = if check.status == :fail do ["Approval code missing for approved transaction" | failures] else failures end {checks, failures} end defp validate_no_approval_code(parsed_response, checks, failures) do approval_code = Parser.get_approval_code(parsed_response) check = %{ name: "No Approval Code (DE38)", expected: "Should be absent for declined transactions", actual: approval_code, status: if(is_nil(approval_code) || String.trim(approval_code) == "", do: :pass, else: :fail) } checks = [check | checks] # This is more informational, not a hard failure {checks, failures} end defp validate_custom_rules(test_case, parsed_response, checks, failures) do # Check if test case has custom validation rules rules = Map.get(test_case, :validation_rules, "") if rules && rules != "" do apply_custom_rules(String.split(rules, ","), parsed_response, checks, failures) else {checks, failures} end end defp apply_custom_rules(rules, parsed_response, checks, failures) do Enum.reduce(rules, {checks, failures}, fn rule, {c, f} -> apply_single_rule(String.trim(rule), parsed_response, c, f) end) end defp apply_single_rule("check_approval", parsed_response, checks, failures) do validate_approval_code(parsed_response, checks, failures) end defp apply_single_rule("check_decline_code", parsed_response, checks, failures) do # Could add more specific decline code validation {checks, failures} end defp apply_single_rule("check_format_error", _parsed_response, checks, failures) do # Response code should indicate format error {checks, failures} end defp apply_single_rule("check_fields", parsed_response, checks, failures) do # Ensure all critical fields are present critical_fields = [2, 3, 4, 11, 39] missing = Enum.filter(critical_fields, fn field -> !Enum.member?(parsed_response.present_fields, field) end) check = %{ name: "Critical Fields Present", expected: "Fields #{inspect(critical_fields)}", actual: "Present: #{inspect(parsed_response.present_fields)}", status: if(missing == [], do: :pass, else: :fail) } checks = [check | checks] failures = if check.status == :fail do ["Missing critical fields: #{inspect(missing)}" | failures] else failures end {checks, failures} end defp apply_single_rule(_rule, _parsed_response, checks, failures) do # Unknown rule, skip {checks, failures} end @doc """ Maps response codes to human-readable descriptions. """ def response_code_description(code) do case code do "00" -> "Approved" "01" -> "Refer to card issuer" "03" -> "Invalid merchant" "04" -> "Pick up card" "05" -> "Do not honor" "12" -> "Invalid transaction" "13" -> "Invalid amount" "14" -> "Invalid card number" "30" -> "Format error" "51" -> "Insufficient funds" "54" -> "Expired card" "55" -> "Incorrect PIN" "58" -> "Transaction not permitted to terminal" "61" -> "Exceeds withdrawal limit" "65" -> "Exceeds withdrawal frequency" "91" -> "Issuer or switch inoperative" "96" -> "System malfunction" _ -> "Unknown response code" end end end