defmodule DaProductAppWeb.TransactionRulesController do use DaProductAppWeb, :controller require Logger alias DaProductApp.TransactionRules @doc """ Evaluate endpoint - Fetches transaction rules for a given merchant and terminal. Expected request format: { "merchant_id": "M123456", "terminal_id": "T987654", "amount": { "value": "2500.00", "currency": "INR" }, "transaction": { "type": "PURCHASE", "stan": "123456", "rrn": "410512345678", "timestamp": "2026-02-24T10:15:30Z" }, "context": { "channel": "POS", "acquirer_id": "ACQ01" } } """ def evaluate(conn, params) do # Extract required fields merchant_id = Map.get(params, "merchant_id") terminal_id = Map.get(params, "terminal_id") # Check if this is an administrative transaction (LOGON, SETTLEMENT, etc.) # These bypass merchant_id requirement and rule evaluation if is_administrative_transaction?(params) do handle_administrative_transaction(conn, params) else # Normal financial transaction flow - requires merchant_id # Extract optional fields for logging amount = Map.get(params, "amount", %{}) transaction = Map.get(params, "transaction", %{}) context_data = Map.get(params, "context", %{}) # Normalize amount for response (convert to decimal string + alpha currency) normalized_amount = normalize_amount(amount) # Get request tracking headers request_id = get_req_header(conn, "x-request-id") |> List.first() || generate_uuid() idempotency_key = get_req_header(conn, "idempotency-key") |> List.first() # Fetch merchant info for header/footer using terminal_id from main database # Queries: pos_terminals → stores → addresses for merchant_name and merchant_address # Also fetches logo, message, description, settings from merchant_configuration merchant_info = if is_nil(terminal_id) or terminal_id == "" do %{user_id: nil, merchant_name: "N/A", merchant_address: "N/A", logo: "N/A", description: "N/A", message: "N/A", model_name: 0, settings: nil, cardholder_name: false} else TransactionRules.fetch_merchant_info_from_terminal(terminal_id, merchant_id) end # Calculate model_name based on TOMS_FLY_RECEIPT and device_model # Priority: TOMS_FLY_RECEIPT (if exists) > device_model check device_model = Map.get(params, "device_model") is_mf919 = calculate_model_name(merchant_info.settings) # Extract merchant name from settings if not available in merchant_info # BASE_MERCHANT_NAME in settings takes priority as the "Merchant Legal Name" merchant_legal_name = extract_merchant_name(merchant_info.merchant_name, merchant_info.settings) receipt_header = %{ logo: merchant_info.logo, merchant_name: merchant_legal_name, merchant_address: merchant_info.merchant_address } receipt_footer = %{ logo: merchant_info.logo, message: merchant_info.message, description: merchant_info.description } # BIN validation for financial transactions - Only BINs in range 400000-599999 allowed bin = Map.get(params, "bin") if !is_valid_mastercard_bin?(bin) do Logger.warning("Invalid BIN detected: #{inspect(if is_binary(bin), do: String.slice(bin, 0, 6), else: bin)}. Only BINs in range 400000-599999 or 222100-272000 accepted. X-Request-Id: #{request_id}") response = %{ service_ref_id: generate_uuid(), code: "31", decision: "DECLINE", message: "Card BIN not supported. Only BINs in range 400000-599999 or 222100-272000 accepted", amount: normalized_amount, provider_time: DateTime.utc_now() |> DateTime.to_iso8601(), header: receipt_header, footer: receipt_footer, model_name: is_mf919, cardholder_name: merchant_info.show_cardholder_name } Logger.info("Returning response: #{inspect(response)}") conn |> put_status(:ok) |> json(response) else # Validate required fields cond do is_nil(merchant_id) or merchant_id == "" -> Logger.warning("Missing merchant_id in request. X-Request-Id: #{request_id}") response = %{ service_ref_id: generate_uuid(), code: "INVALID_REQUEST", decision: "DECLINE", message: "merchant_id is required", amount: normalize_amount(amount), provider_time: DateTime.utc_now() |> DateTime.to_iso8601(), header: receipt_header, footer: receipt_footer, model_name: is_mf919, cardholder_name: merchant_info.show_cardholder_name } Logger.info("Returning response: #{inspect(response)}") conn |> put_status(:bad_request) |> json(response) is_nil(terminal_id) or terminal_id == "" -> Logger.warning("Missing terminal_id in request. X-Request-Id: #{request_id}") response = %{ service_ref_id: generate_uuid(), code: "INVALID_REQUEST", decision: "DECLINE", message: "terminal_id is required", amount: normalize_amount(amount), provider_time: DateTime.utc_now() |> DateTime.to_iso8601(), header: receipt_header, footer: receipt_footer, model_name: is_mf919, cardholder_name: merchant_info.show_cardholder_name } Logger.info("Returning response: #{inspect(response)}") conn |> put_status(:bad_request) |> json(response) true -> # Fetch rules from database case TransactionRules.fetch_rules_for(merchant_id, terminal_id) do {:ok, rules} -> service_ref_id = generate_uuid() provider_time = DateTime.utc_now() |> DateTime.to_iso8601() # Log the request Logger.info(""" Transaction Rules Evaluation: Service Ref ID: #{service_ref_id} X-Request-Id: #{request_id} Idempotency-Key: #{idempotency_key} Merchant ID: #{merchant_id} Terminal ID: #{terminal_id} Amount: #{inspect(amount)} Transaction: #{inspect(transaction)} Rules Found: #{length(rules)} """) # Evaluate rules against transaction case TransactionRules.evaluate_transaction(rules, params) do {:allow, code, message} -> Logger.info("Decision: ALLOW, Code: #{code}, Message: #{message}") response = %{ service_ref_id: service_ref_id, code: code, decision: "ALLOW", message: message, amount: normalized_amount, provider_time: provider_time, header: receipt_header, footer: receipt_footer, model_name: is_mf919, cardholder_name: merchant_info.show_cardholder_name } Logger.info("Returning response: #{inspect(response)}") conn |> put_status(:ok) |> json(response) {:decline, code, message, rule} -> Logger.warning(""" Decision: DECLINE Code: #{code} Message: #{message} Rule ID: #{rule.rule_id} Rule Name: #{rule.rule_name} """) response = %{ service_ref_id: service_ref_id, code: code, decision: "DECLINE", message: message, amount: normalized_amount, rule: %{ rule_id: rule.rule_id, rule_name: rule.rule_name }, provider_time: provider_time, header: receipt_header, footer: receipt_footer, model_name: is_mf919, cardholder_name: merchant_info.show_cardholder_name } Logger.info("Returning response: #{inspect(response)}") conn |> put_status(:ok) |> json(response) {:flag, code, message, rule} -> Logger.info(""" Decision: FLAG Code: #{code} Message: #{message} Rule ID: #{rule.rule_id} Rule Name: #{rule.rule_name} """) response = %{ service_ref_id: service_ref_id, code: code, decision: "FLAG", message: message, amount: normalized_amount, rule: %{ rule_id: rule.rule_id, rule_name: rule.rule_name }, provider_time: provider_time, header: receipt_header, footer: receipt_footer, model_name: is_mf919, cardholder_name: merchant_info.show_cardholder_name } Logger.info("Returning response: #{inspect(response)}") conn |> put_status(:ok) |> json(response) end {:error, reason} -> service_ref_id = generate_uuid() Logger.error("Failed to fetch rules: #{inspect(reason)}. X-Request-Id: #{request_id}") response = %{ service_ref_id: service_ref_id, code: "PROVIDER_ERROR", decision: "DECLINE", message: "Failed to retrieve transaction rules", amount: normalize_amount(amount), provider_time: DateTime.utc_now() |> DateTime.to_iso8601(), header: receipt_header, footer: receipt_footer, model_name: is_mf919, cardholder_name: merchant_info.show_cardholder_name } Logger.info("Returning response: #{inspect(response)}") conn |> put_status(:internal_server_error) |> json(response) end end end end # end of BIN validation else block end # Generate UUID for service reference ID defp generate_uuid do "SVR-" <> (DateTime.utc_now() |> DateTime.to_iso8601() |> String.replace(~r/[^0-9]/, "") |> String.slice(0, 14)) <> "-" <> (:rand.uniform(999999) |> Integer.to_string() |> String.pad_leading(6, "0")) end # Normalize amount object for response # Converts ISO 8583 format to decimal string and numeric currency codes to alpha codes defp normalize_amount(amount) when is_map(amount) do value = Map.get(amount, "value", "0") currency = Map.get(amount, "currency", "784") %{ value: format_amount_value(value), currency: convert_currency_code(currency) } end defp normalize_amount(_), do: %{value: "0.00", currency: "XXX"} # Parse and format amount value to decimal string defp format_amount_value(value) when is_binary(value) do parsed = parse_amount_value(value) # Format to 2 decimal places :erlang.float_to_binary(parsed, decimals: 2) end defp format_amount_value(value) when is_number(value) do :erlang.float_to_binary(value * 1.0, decimals: 2) end defp format_amount_value(_), do: "0.00" # Parse amount value (handles ISO 8583 format) defp parse_amount_value(value) when is_binary(value) do cond do String.contains?(value, ".") -> {float_val, _} = Float.parse(value) float_val String.length(value) > 6 and String.match?(value, ~r/^\d+$/) -> # ISO 8583 format: amount in minor units (last 2 digits are decimals) {int_val, _} = Integer.parse(value) int_val / 100.0 true -> {int_val, _} = Integer.parse(value) int_val * 1.0 end rescue _ -> 0.0 end defp parse_amount_value(_), do: 0.0 # Convert numeric currency code to alpha code (ISO 4217) defp convert_currency_code("784"), do: "AED" # UAE Dirham defp convert_currency_code("356"), do: "INR" # Indian Rupee defp convert_currency_code("840"), do: "USD" # US Dollar defp convert_currency_code("978"), do: "EUR" # Euro defp convert_currency_code("826"), do: "GBP" # British Pound defp convert_currency_code("392"), do: "JPY" # Japanese Yen defp convert_currency_code("156"), do: "CNY" # Chinese Yuan defp convert_currency_code("682"), do: "SAR" # Saudi Riyal defp convert_currency_code("414"), do: "KWD" # Kuwaiti Dinar defp convert_currency_code("512"), do: "OMR" # Omani Rial defp convert_currency_code("634"), do: "QAR" # Qatari Riyal defp convert_currency_code("048"), do: "BHD" # Bahraini Dinar # If already alpha code, return as-is defp convert_currency_code(code) when is_binary(code) and byte_size(code) == 3 do if String.match?(code, ~r/^[A-Z]{3}$/), do: code, else: "XXX" end defp convert_currency_code(_), do: "XXX" # Unknown currency # Check if device model contains MF919 (case-insensitive) # Returns true if device_model contains "mf919" anywhere in the string # Returns false for nil, empty string, or other device models defp check_device_model(nil), do: false defp check_device_model(""), do: false defp check_device_model(device_model) when is_binary(device_model) do device_model |> String.downcase() |> String.contains?("mf919") end defp check_device_model(_), do: false # Calculate model_name (cloud receipt eligibility) based on TOMS_FLY_RECEIPT setting only. # Returns boolean: true (cloud receipt enabled), false (cloud receipt disabled) defp calculate_model_name(settings) do case extract_toms_fly_receipt(settings) do "1" -> true _ -> false end end # Extract TOMS_FLY_RECEIPT value from settings JSON # Returns "0", "1", or nil defp extract_toms_fly_receipt(nil), do: nil defp extract_toms_fly_receipt(settings) when is_map(settings) do case Map.get(settings, "TOMS_FLY_RECEIPT") do value when is_binary(value) -> value value when is_integer(value) -> Integer.to_string(value) _ -> nil end end defp extract_toms_fly_receipt(_), do: nil # Check if BIN is valid (must be in range 400000-599999) # Returns true if BIN is exactly 6 digits and falls in range 400000-599999 # Returns false for nil, empty string, or BINs outside this range defp is_valid_mastercard_bin?(nil), do: false defp is_valid_mastercard_bin?(""), do: false defp is_valid_mastercard_bin?(bin) when is_binary(bin) do # Check if BIN is exactly 6 digits and numeric if String.match?(bin, ~r/^\d{6}$/) do case Integer.parse(bin) do {bin_number, ""} -> bin_number in 400_000..599_999 or bin_number in 222_100..272_000 _ -> false end else false end end defp is_valid_mastercard_bin?(_), do: false # Extract merchant legal name - prioritizes merchant_name from stores, then settings # Priority: merchant_name (from stores.name) > settings["BASE_MERCHANT_NAME"] > "N/A" defp extract_merchant_name(merchant_name, settings) do cond do # First priority: Use merchant_name from stores.name if it exists is_binary(merchant_name) and merchant_name != "" and merchant_name != "N/A" -> merchant_name # Second priority: Fall back to BASE_MERCHANT_NAME from settings if stores.name is missing is_map(settings) and Map.has_key?(settings, "BASE_MERCHANT_NAME") -> case Map.get(settings, "BASE_MERCHANT_NAME") do name when is_binary(name) and name != "" -> name _ -> "N/A" end # Final fallback true -> "N/A" end end # Check if transaction is administrative (LOGON, SETTLEMENT, etc.) # Administrative transactions bypass merchant_id requirement and rule evaluation # These are network/device management messages, not merchant financial transactions defp is_administrative_transaction?(params) do mti = Map.get(params, "mti") case mti do "0500" -> true # Settlement request "0510" -> true # Settlement response "0800" -> true # Network management/Logon request "0810" -> true # Network management/Logon response _ -> false end end # Handle administrative transactions (LOGON, SETTLEMENT, etc.) # These don't require merchant_id and bypass rule evaluation # Returns immediate ALLOW with appropriate message defp handle_administrative_transaction(conn, params) do mti = Map.get(params, "mti") processing_code = Map.get(params, "processing_code") terminal_id = Map.get(params, "terminal_id") service_ref_id = generate_uuid() provider_time = DateTime.utc_now() |> DateTime.to_iso8601() request_id = get_req_header(conn, "x-request-id") |> List.first() || generate_uuid() # Determine transaction type for logging and response transaction_type = case mti do "0500" -> "SETTLEMENT" "0510" -> "SETTLEMENT_RESPONSE" "0800" -> case processing_code do "990000" -> "LOGON" "991000" -> "LOGOFF" "992000" -> "ECHO_TEST" _ -> "NETWORK_MANAGEMENT" end "0810" -> "NETWORK_MANAGEMENT_RESPONSE" _ -> "ADMINISTRATIVE" end Logger.info(""" Administrative Transaction - Auto-Approved: Service Ref ID: #{service_ref_id} X-Request-Id: #{request_id} Type: #{transaction_type} MTI: #{mti} Processing Code: #{processing_code} Terminal ID: #{terminal_id} Note: Bypassed merchant_id requirement and rule evaluation """) response = %{ service_ref_id: service_ref_id, code: "00", decision: "ALLOW", message: "#{transaction_type} approved", provider_time: provider_time } Logger.info("Returning response: #{inspect(response)}") # Return success response for administrative transactions conn |> put_status(:ok) |> json(response) end end