defmodule DaProductApp.MercuryISO8583.Message do @moduledoc """ Structure and validation for Mercury ISO8583 messages. Represents a complete ISO8583 message with TPDU, MTI, bitmap, and fields. """ alias DaProductApp.MercuryISO8583.{Utils, Formats} @doc """ Message structure for Mercury ISO8583 messages. ## Fields - `:mti` - Message Type Indicator (4 digits) - `:tpdu` - Transport Protocol Data Unit (5 bytes, optional) - `:fields` - Map of field numbers to values - `:bitmap` - Binary bitmap indicating present fields - `:raw_bitmap` - Original bitmap from decoded message """ defstruct [ :mti, :tpdu, fields: %{}, bitmap: <<>>, raw_bitmap: <<>> ] @type t :: %__MODULE__{ mti: String.t() | nil, tpdu: binary() | nil, fields: map(), bitmap: binary(), raw_bitmap: binary() } @doc """ Create a new message with the given MTI. ## Examples iex> Message.new("0800") %Message{mti: "0800", fields: %{}, bitmap: <<>>, tpdu: nil} """ def new(mti \\ nil) do %__MODULE__{ mti: mti, fields: %{}, bitmap: <<>>, tpdu: nil, raw_bitmap: <<>> } end @doc """ Create a new message from a map of fields. ## Examples iex> Message.from_map(%{"0" => "0800", "11" => "646465", "70" => "001"}) %Message{mti: "0800", fields: %{"11" => "646465", "70" => "001"}, ...} """ def from_map(fields_map) when is_map(fields_map) do {mti, other_fields} = Map.pop(fields_map, "0") %__MODULE__{ mti: mti, fields: other_fields, bitmap: <<>>, tpdu: nil, raw_bitmap: <<>> } end @doc """ Convert message to map format. ## Examples iex> message = %Message{mti: "0800", fields: %{"11" => "646465"}} iex> Message.to_map(message) %{"0" => "0800", "11" => "646465"} """ def to_map(%__MODULE__{} = message) do base_map = if message.mti, do: %{"0" => message.mti}, else: %{} Map.merge(base_map, message.fields) end @doc """ Get field value from message. Supports field numbers (string/integer) and field names (atoms). ## Examples iex> Message.get_field(message, "11") "646465" iex> Message.get_field(message, :stan) "646465" """ def get_field(%__MODULE__{} = message, field) do field_str = Utils.field_to_string(field) case field_str do "0" -> message.mti _ -> Map.get(message.fields, field_str) end end @doc """ Set field value in message. Supports field numbers (string/integer) and field names (atoms). ## Examples iex> Message.set_field(message, "11", "646465") %Message{...} iex> Message.set_field(message, :stan, "646465") %Message{...} """ def set_field(%__MODULE__{} = message, field, value) do field_str = Utils.field_to_string(field) case field_str do "0" -> %{message | mti: value} _ -> updated_fields = Map.put(message.fields, field_str, value) %{message | fields: updated_fields} end end @doc """ Remove field from message. ## Examples iex> Message.remove_field(message, "11") %Message{...} """ def remove_field(%__MODULE__{} = message, field) do field_str = Utils.field_to_string(field) case field_str do "0" -> %{message | mti: nil} _ -> updated_fields = Map.delete(message.fields, field_str) %{message | fields: updated_fields} end end @doc """ Check if field is present in message. ## Examples iex> Message.has_field?(message, "11") true """ def has_field?(%__MODULE__{} = message, field) do field_str = Utils.field_to_string(field) case field_str do "0" -> message.mti != nil _ -> Map.has_key?(message.fields, field_str) end end @doc """ Get all present field numbers in the message. ## Examples iex> Message.present_fields(message) ["0", "11", "70"] """ def present_fields(%__MODULE__{} = message) do field_list = Map.keys(message.fields) if message.mti do ["0" | field_list] else field_list end |> Enum.sort_by(&Utils.field_to_integer/1) end @doc """ Validate message structure and field content. ## Examples iex> Message.validate(message) {:ok, message} iex> Message.validate(invalid_message) {:error, "Field 4 content exceeds maximum length"} """ def validate(%__MODULE__{} = message) do with {:ok, _} <- validate_mti(message.mti), {:ok, _} <- validate_fields(message.fields) do {:ok, message} end end @doc """ Validate MTI format. """ def validate_mti(nil), do: {:error, "MTI is required"} def validate_mti(mti) when is_binary(mti) do cond do String.length(mti) != 4 -> {:error, "MTI must be exactly 4 digits"} not Utils.numeric?(mti) -> {:error, "MTI must contain only numeric characters"} true -> {:ok, mti} end end def validate_mti(_), do: {:error, "MTI must be a string"} @doc """ Validate all fields in the message. """ def validate_fields(fields) when is_map(fields) do fields |> Enum.reduce_while({:ok, fields}, fn {field, content}, acc -> case Formats.validate_field_content(field, content) do {:ok, _} -> {:cont, acc} {:error, reason} -> {:halt, {:error, reason}} end end) end @doc """ Check if message is a request (MTI ending in 0 or 2). """ def request?(%__MODULE__{mti: mti}) when is_binary(mti) do String.ends_with?(mti, ["0", "2"]) end def request?(_), do: false @doc """ Check if message is a response (MTI ending in 1 or 3). """ def response?(%__MODULE__{mti: mti}) when is_binary(mti) do String.ends_with?(mti, ["1", "3"]) end def response?(_), do: false @doc """ Get message class from MTI. ## Returns - `:authorization` - Authorization messages (02xx) - `:financial` - Financial messages (12xx) - `:file_action` - File action messages (22xx) - `:reversal` - Reversal messages (04xx) - `:reconciliation` - Reconciliation messages (5xxx) - `:administrative` - Administrative messages (6xxx) - `:fee_collection` - Fee collection messages (7xxx) - `:network_management` - Network management messages (8xxx) - `:unknown` - Unknown message type """ def message_class(%__MODULE__{mti: mti}) when is_binary(mti) do case String.slice(mti, 1, 1) do "0" -> :authorization "1" -> :financial "2" -> :file_action "4" -> :reversal "5" -> :reconciliation "6" -> :administrative "7" -> :fee_collection "8" -> :network_management _ -> :unknown end end def message_class(_), do: :unknown @doc """ Get message function from MTI. ## Returns - `:request` - Request message (x0xx) - `:request_response` - Request response (x1xx) - `:advice` - Advice message (x2xx) - `:advice_response` - Advice response (x3xx) - `:unknown` - Unknown function """ def message_function(%__MODULE__{mti: mti}) when is_binary(mti) do case String.slice(mti, 2, 1) do "0" -> :request "1" -> :request_response "2" -> :advice "3" -> :advice_response _ -> :unknown end end def message_function(_), do: :unknown @doc """ Generate response MTI for a request MTI. ## Examples iex> Message.response_mti("0200") "0210" iex> Message.response_mti("0800") "0810" """ def response_mti(request_mti) when is_binary(request_mti) and byte_size(request_mti) == 4 do case String.slice(request_mti, 2, 1) do "0" -> String.slice(request_mti, 0, 2) <> "1" <> String.slice(request_mti, 3, 1) "2" -> String.slice(request_mti, 0, 2) <> "3" <> String.slice(request_mti, 3, 1) _ -> request_mti # Already a response or unknown format end end def response_mti(_), do: nil @doc """ Create a response message from a request message. Copies relevant fields and sets appropriate response MTI. """ def create_response(%__MODULE__{} = request_message, response_code \\ "00") do response_mti = response_mti(request_message.mti) # Copy fields that should be echoed in response echo_fields = ["2", "3", "4", "7", "11", "12", "13", "22", "25", "37", "41", "42"] echoed_fields = request_message.fields |> Enum.filter(fn {field, _} -> field in echo_fields end) |> Enum.into(%{}) # Add response code response_fields = Map.put(echoed_fields, "39", response_code) %__MODULE__{ mti: response_mti, fields: response_fields, tpdu: request_message.tpdu, bitmap: <<>>, raw_bitmap: <<>> } end end