defmodule DaProductApp.Switch.GatewayRouter do @moduledoc """ Dedicated gateway routing module for processing transactions through payment gateways. This module handles the routing of ISO8583 messages to modern payment gateways (like MPGS, VISA Direct, etc.) that use REST APIs instead of traditional ISO8583 protocols. ## Features - Gateway processor selection and routing - ISO8583 to gateway message conversion - Gateway response to ISO8583 conversion - Gateway-specific error handling - Support for multiple gateway types ## Supported Gateways - MPGS (Mastercard Payment Gateway Services) - Future: VISA Direct, Amex Gateway, etc. ## Usage # Process transaction through gateway {:ok, iso_response} = GatewayRouter.process_transaction(iso_message, gateway_config) """ require Logger alias DaProductApp.MercuryISO8583.Packagers.ISOMsg alias DaProductApp.MercuryISO8583.Packagers.ISO87BPackager alias DaProductApp.Acquirer.GenericProcessor alias DaProductApp.Acquirer.Mastercard.MessageTranslator alias DaProductApp.Acquirer.Schemas.PosTempTransaction @doc """ Process an ISO8583 message through the appropriate gateway. This function: 1. Determines the correct gateway processor 2. Converts ISO8583 message to gateway format 3. Processes the transaction through the gateway 4. Converts gateway response back to ISO8583 format ## Parameters - `iso_message`: The ISO8583 message to process - `gateway_config`: Gateway configuration from upstream networks ## Returns - `{:ok, response_bytes}` - ISO8583 response bytes - `{:error, reason}` - Processing error """ @spec process_transaction(ISOMsg.t(), map()) :: {:ok, binary()} | {:error, term()} def process_transaction(%ISOMsg{} = iso_message, gateway_config) do gateway_name = Map.get(gateway_config, :network_name, "unknown") Logger.info("Processing transaction through gateway: #{gateway_name}") try do with {:ok, processor} <- get_gateway_processor(gateway_config), {:ok, gateway_response} <- process_through_gateway(processor, iso_message, gateway_config), {:ok, iso_response_bytes} <- convert_gateway_response_to_iso(gateway_response, iso_message, gateway_config) do Logger.info("Gateway transaction completed successfully for: #{gateway_name}") {:ok, iso_response_bytes} else {:error, reason} -> Logger.error("Gateway processing failed for #{gateway_name}: #{inspect(reason)}") {:error, {:gateway_processing_failed, reason}} end rescue exception -> Logger.error("Exception in gateway processing for #{gateway_name}: #{inspect(exception)}") {:error, {:gateway_exception, exception}} end end @doc """ Get the appropriate gateway processor for the given configuration. ## Parameters - `gateway_config`: Gateway configuration map ## Returns - `{:ok, processor_module}` - Gateway processor module - `{:error, reason}` - If gateway type is unsupported """ @spec get_gateway_processor(map()) :: {:ok, module()} | {:error, term()} def get_gateway_processor(gateway_config) do gateway_type = Map.get(gateway_config, :gateway_type, "MPGS") case String.upcase(gateway_type) do "MPGS" -> Logger.debug("Using GenericProcessor for MPGS gateway") {:ok, GenericProcessor} "VISA_DIRECT" -> Logger.debug("VISA Direct gateway support coming soon") {:error, {:unsupported_gateway_type, "VISA Direct support not yet implemented"}} "AMEX_GATEWAY" -> Logger.debug("Amex Gateway support coming soon") {:error, {:unsupported_gateway_type, "Amex Gateway support not yet implemented"}} unknown_type -> Logger.error("Unsupported gateway type: #{unknown_type}") {:error, {:unsupported_gateway_type, unknown_type}} end end @doc """ Check if a gateway type is supported. ## Parameters - `gateway_type`: The gateway type string ## Returns - `true` if supported, `false` otherwise """ @spec supported_gateway?(String.t()) :: boolean() def supported_gateway?(gateway_type) when is_binary(gateway_type) do gateway_type |> String.upcase() |> case do "MPGS" -> true "VISA_DIRECT" -> false # Coming soon "AMEX_GATEWAY" -> false # Coming soon _ -> false end end def supported_gateway?(_), do: false @doc """ List all supported gateway types. ## Returns - List of supported gateway type strings """ @spec supported_gateways() :: [String.t()] def supported_gateways do ["MPGS"] end # ============================== # PRIVATE FUNCTIONS # ============================== defp process_through_gateway(processor, iso_message, gateway_config) do Logger.debug("Processing transaction through gateway processor: #{inspect(processor)}") # Convert gateway config to format expected by processor processor_config = prepare_processor_config(gateway_config) # Convert ISOMsg to a plain payment data map before passing to GenericProcessor. # GenericProcessor.store_transaction_with_metadata accesses internal_msg with # [] bracket syntax (e.g. internal_msg[:field_41]), which requires the Access # behaviour. ISOMsg does not implement Access, so we convert here first. # The original iso_message (ISOMsg) is still used by convert_gateway_response_to_iso. with {:ok, payment_data} <- MessageTranslator.iso8583_to_payment_data(iso_message) do case processor.process_transaction("MPGS", processor_config, payment_data, []) do {:ok, %{result: result} = gateway_response} -> Logger.debug("Gateway processor returned: #{result}") {:ok, gateway_response} {:ok, %PosTempTransaction{} = transaction} -> Logger.debug("Gateway processor returned stored transaction: #{transaction.id}") {:ok, build_gateway_response_from_transaction(transaction)} {:error, reason} -> Logger.error("Gateway processor failed: #{inspect(reason)}") {:error, {:processor_failed, reason}} end end end defp build_gateway_response_from_transaction(%PosTempTransaction{} = transaction) do metadata = case Jason.decode(transaction.metadata || "{}") do {:ok, parsed} -> parsed _ -> %{} end %{ result: if(transaction.gateway_status == "SUCCESS", do: :success, else: :error), gateway_transaction_id: transaction.gateway_reference_id, gateway_status: transaction.gateway_status, response_code: get_in(metadata, ["gateway", "response", "gateway_code"]) || "96", approval_code: get_in(metadata, ["gateway", "response", "authorization_code"]), metadata: metadata } end defp prepare_processor_config(gateway_config) do # Pass the full gateway config to the processor (don't flatten it) # The processor expects the complete nested structure including mpgs_config gateway_config end defp convert_gateway_response_to_iso(gateway_response, original_message, _gateway_config) do Logger.debug("Converting gateway response to ISO8583 format") try do # Extract response data from gateway response %{ result: _result, gateway_transaction_id: gateway_txn_id, gateway_status: _gateway_status, response_code: response_code, approval_code: approval_code, metadata: _metadata } = gateway_response # Create ISO8583 response message original_mti = ISOMsg.get_mti(original_message) response_mti = create_response_mti(original_mti) # Get packager from original message or use default ISO87BPackager packager = ISOMsg.get_packager(original_message) || ISO87BPackager response_message = ISOMsg.new() |> ISOMsg.set_mti(response_mti) |> ISOMsg.set_packager(packager) # Copy essential fields from original message response_with_fields = copy_essential_fields_from_original(response_message, original_message) retrieval_reference_number = select_retrieval_reference_number(original_message, gateway_txn_id) # Set gateway response fields final_response = response_with_fields |> ISOMsg.set(37, retrieval_reference_number) # Reference number |> maybe_set_field(38, approval_code) # Auth ID |> ISOMsg.set(39, response_code) # Response code # Pack the response message to bytes case ISOMsg.pack(final_response) do {:ok, packed_bytes} -> Logger.debug("Successfully converted gateway response to ISO8583") {:ok, packed_bytes} {:error, reason} -> Logger.error("Failed to pack ISO8583 response: #{inspect(reason)}") {:error, {:iso_pack_failed, reason}} end rescue exception -> Logger.error("Failed to convert gateway response to ISO: #{inspect(exception)}") {:error, {:iso_conversion_failed, exception}} end end defp create_response_mti(request_mti) do # ISO8583 MTI format: VCCF (Version, Class, Function, Origin) # Response function digit = request function digit + 1 # e.g. 0200 (func=0) -> 0210 (func=1), 0220 (func=2) -> 0230 (func=3) case String.length(request_mti) do 4 -> version = String.slice(request_mti, 0, 1) class = String.slice(request_mti, 1, 1) function = String.to_integer(String.slice(request_mti, 2, 1)) origin = String.slice(request_mti, 3, 1) version <> class <> Integer.to_string(function + 1) <> origin _ -> "0210" end end defp copy_essential_fields_from_original(%ISOMsg{} = response_message, %ISOMsg{} = original_message) do # Essential fields to copy from request to response essential_fields = [ 11, # STAN (Systems Trace Audit Number) 7, # Date/Time 41, # Terminal ID 42, # Merchant ID 2, # PAN (Primary Account Number) 3, # Processing Code 4, # Transaction Amount 49 # Currency Code ] Enum.reduce(essential_fields, response_message, fn field, acc -> case ISOMsg.get_field(original_message, field) do nil -> acc value -> ISOMsg.set(acc, field, value) end end) end defp select_retrieval_reference_number(%ISOMsg{} = original_message, gateway_txn_id) do case normalize_retrieval_reference_number(ISOMsg.get_field(original_message, 37)) do {:ok, rrn} -> rrn :error -> case normalize_retrieval_reference_number(gateway_txn_id) do {:ok, rrn} -> rrn :error -> rrn = generate_reference_number() Logger.warning( "Gateway transaction id is not a valid ISO field 37 value; using generated RRN #{rrn} instead of #{inspect(gateway_txn_id)}" ) rrn end end end defp normalize_retrieval_reference_number(value) when is_binary(value) do trimmed_value = String.trim(value) if byte_size(trimmed_value) == 12 do {:ok, trimmed_value} else :error end end defp normalize_retrieval_reference_number(_), do: :error defp maybe_set_field(%ISOMsg{} = message, _field, nil), do: message defp maybe_set_field(%ISOMsg{} = message, _field, ""), do: message defp maybe_set_field(%ISOMsg{} = message, field, value), do: ISOMsg.set(message, field, value) defp generate_reference_number do # Generate a 12-digit reference number :rand.uniform(999_999_999_999) |> Integer.to_string() |> String.pad_leading(12, "0") end @doc """ Validate gateway configuration for completeness. ## Parameters - `gateway_config`: Gateway configuration map ## Returns - `:ok` if valid - `{:error, reason}` if invalid """ @spec validate_gateway_config(map()) :: :ok | {:error, term()} def validate_gateway_config(gateway_config) do required_fields = [:gateway_type, :base_url] case check_required_fields(gateway_config, required_fields) do :ok -> validate_gateway_specific_config(gateway_config) error -> error end end defp check_required_fields(config, required_fields) do missing_fields = Enum.filter(required_fields, &(not Map.has_key?(config, &1))) if missing_fields == [] do :ok else {:error, {:missing_required_fields, missing_fields}} end end defp validate_gateway_specific_config(gateway_config) do gateway_type = Map.get(gateway_config, :gateway_type, "") case String.upcase(gateway_type) do "MPGS" -> validate_mpgs_config(gateway_config) _ -> {:error, {:unsupported_gateway_type, gateway_type}} end end defp validate_mpgs_config(gateway_config) do mpgs_config = Map.get(gateway_config, :mpgs_config, %{}) auth_config = Map.get(gateway_config, :auth_config, %{}) required_mpgs_fields = [:merchant_id] required_auth_fields = [:consumer_key] with :ok <- check_required_fields(mpgs_config, required_mpgs_fields), :ok <- check_required_fields(auth_config, required_auth_fields) do :ok else {:error, {:missing_required_fields, fields}} when is_list(fields) -> {:error, {:incomplete_mpgs_config, fields}} error -> error end end end