defmodule DaProductApp.TransactionRules do @moduledoc """ Context module for managing and querying transaction rules. """ import Ecto.Query alias DaProductApp.Repo alias DaProductApp.Repos.ShukriaMmsRepo alias DaProductApp.Schemas.ShukriaMms.TransactionRule @doc """ Fetch all enabled transaction rules for a given merchant_id and optional terminal_id. Rules are returned with priority order: 1. Terminal-specific rules (if terminal_id provided) 2. Merchant-specific rules 3. Global rules Returns rules ordered by priority (lower priority number = higher precedence). """ def fetch_rules_for(merchant_id, terminal_id \\ nil) do query = from r in TransactionRule, where: r.enabled == true and is_nil(r.deleted_at), order_by: [asc: r.priority, desc: r.created_at] query = if terminal_id do from r in query, where: (r.scope == :terminal and r.terminal_id == ^terminal_id) or (r.scope == :merchant and r.merchant_id == ^merchant_id) or r.scope == :global else from r in query, where: (r.scope == :merchant and r.merchant_id == ^merchant_id) or r.scope == :global end rules = ShukriaMmsRepo.all(query) {:ok, rules} rescue e -> {:error, e} end @doc """ Fetch a single transaction rule by rule_id. """ def get_rule_by_id(rule_id) do case ShukriaMmsRepo.get_by(TransactionRule, rule_id: rule_id) do nil -> {:not_found, "Rule not found"} rule -> {:ok, rule} end rescue e -> {:error, e} end @doc """ Format rules into a response structure for the API. Returns a map with rules grouped by type along with metadata. """ def format_rules_response(rules) do %{ rules: Enum.map(rules, fn rule -> %{ rule_id: rule.rule_id, rule_name: rule.rule_name, rule_type: rule.rule_type, response_code: rule.response_code, scope: rule.scope, params: rule.params, priority: rule.priority } end), total_count: length(rules) } end @doc """ Fetch merchant info (name and address) by merchant_id. Looks up user_metadata by merchant_reference_number to get user_id, then fetches the kyc_requests data JSON to extract merchant name and address. Falls back to default values if data is not found or an error occurs. """ def fetch_merchant_info(merchant_id) do require Logger user_metadata = from(m in "user_metadata", where: m.merchant_refrence_number == ^merchant_id, select: %{user_id: m.user_id}, limit: 1 ) |> ShukriaMmsRepo.one() Logger.info("fetch_merchant_info - merchant_id: #{merchant_id}, user_metadata: #{inspect(user_metadata)}") case user_metadata do nil -> Logger.warning("fetch_merchant_info - no user_metadata found for merchant_id: #{merchant_id}") default_merchant_info() %{user_id: user_id} -> kyc = from(k in "kyc_requests", where: k.user_id == ^user_id, select: %{data: k.data}, limit: 1 ) |> ShukriaMmsRepo.one() Logger.info("fetch_merchant_info - user_id: #{user_id}, kyc: #{inspect(kyc)}") merchant_config = from(c in "merchant_configuration", where: c.user_id == ^user_id, select: %{logo: c.logo, description: c.description, message: c.message}, limit: 1 ) |> ShukriaMmsRepo.one() Logger.info("fetch_merchant_info - merchant_config: #{inspect(merchant_config)}") {merchant_name, merchant_address} = case kyc do %{data: data_map} when is_map(data_map) -> {Map.get(data_map, "Merchant's Legal Name", "N/A"), Map.get(data_map, "Address", "N/A")} %{data: data_str} when is_binary(data_str) -> case Jason.decode(data_str) do {:ok, data_map} -> {Map.get(data_map, "Merchant's Legal Name", "N/A"), Map.get(data_map, "Address", "N/A")} _ -> Logger.warning("fetch_merchant_info - failed to parse kyc data JSON") {"N/A", "N/A"} end _ -> Logger.warning("fetch_merchant_info - no kyc_requests found for user_id: #{user_id}") {"N/A", "N/A"} end {logo, description, message} = case merchant_config do %{logo: logo_val, description: desc, message: msg} -> {logo_val || "N/A", desc || "N/A", msg || "N/A"} _ -> {"N/A", "N/A", "N/A"} end %{ user_id: user_id, merchant_name: merchant_name, merchant_address: merchant_address, logo: logo, description: description, message: message } end rescue e -> require Logger Logger.error("fetch_merchant_info failed: #{inspect(e)}") default_merchant_info() end defp default_merchant_info do %{ user_id: nil, merchant_name: "N/A", merchant_address: "N/A", logo: "N/A", description: "N/A", message: "N/A" } end @doc """ Evaluate transaction against rules and return decision. Returns: - {:allow, code, message} - Transaction allowed - {:decline, code, message, rule} - Transaction declined by specific rule - {:flag, code, message, rule} - Transaction flagged for review """ def evaluate_transaction(rules, transaction_data) do # Extract transaction details amount_value = get_in(transaction_data, ["amount", "value"]) |> parse_amount() amount_currency = get_in(transaction_data, ["amount", "currency"]) || "784" transaction_type = get_in(transaction_data, ["transaction", "type"]) || "PURCHASE" merchant_id = transaction_data["merchant_id"] terminal_id = transaction_data["terminal_id"] # Use transaction timestamp if present, else fallback to server time txn_timestamp = case get_in(transaction_data, ["transaction", "timestamp"]) do nil -> DateTime.utc_now() ts when is_binary(ts) -> case DateTime.from_iso8601(ts) do {:ok, dt, _offset} -> dt _ -> DateTime.utc_now() end _ -> DateTime.utc_now() end require Logger Logger.info("Evaluating transaction - Amount: #{amount_value}, Rules count: #{length(rules)}, Timestamp: #{txn_timestamp}") # Evaluate each rule in priority order case evaluate_rules_sequence(rules, %{ amount: amount_value, currency: amount_currency, type: transaction_type, merchant_id: merchant_id, terminal_id: terminal_id, timestamp: txn_timestamp }) do {:decline, rule, reason} -> Logger.info("DECLINED - Rule: #{rule.rule_id}, Reason: #{reason}") {:decline, rule.response_code || "05", reason, rule} {:flag, rule, reason} -> Logger.info("FLAGGED - Rule: #{rule.rule_id}, Reason: #{reason}") {:flag, "FLAG", reason, rule} :allow -> Logger.info("ALLOWED - All rules passed") {:allow, "00", "Approved by rules engine"} end end # Parse amount from string format (handles both "000000002500" and "2500.00" formats) defp parse_amount(nil), do: 0.0 defp parse_amount(value) when is_number(value), do: value * 1.0 defp parse_amount(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+$/) -> # Assume 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 end # Evaluate rules in sequence, stop at first decline/flag defp evaluate_rules_sequence([], _context), do: :allow defp evaluate_rules_sequence([rule | rest], context) do case evaluate_single_rule(rule, context) do :allow -> evaluate_rules_sequence(rest, context) {:decline, reason} -> {:decline, rule, reason} {:flag, reason} -> {:flag, rule, reason} end end # Evaluate a single rule against transaction context defp evaluate_single_rule(rule, context) do case rule.rule_type do "MIN_AMOUNT" -> evaluate_min_amount(rule, context) "MAX_AMOUNT" -> evaluate_max_amount(rule, context) "TIME_WINDOW" -> evaluate_time_window(rule, context) "BLACKLIST" -> evaluate_blacklist(rule, context) "VELOCITY_COUNT" -> evaluate_velocity_count(rule, context) "VELOCITY_AMOUNT" -> evaluate_velocity_amount(rule, context) "WEEKLY_TOTAL" -> evaluate_velocity(rule, context) "DAILY_LIMIT" -> evaluate_velocity(rule, context) _ -> :allow # Unknown rule types are ignored end end # MIN_AMOUNT rule: Decline if transaction amount is below minimum defp evaluate_min_amount(rule, context) do min_amount = get_rule_param(rule, "min_amount", 0.0) |> parse_amount() if context.amount < min_amount do {:decline, "Transaction amount #{context.amount} is below minimum #{min_amount}"} else :allow end end # MAX_AMOUNT rule: Decline if transaction amount exceeds maximum defp evaluate_max_amount(rule, context) do max_amount = get_rule_param(rule, "max_amount", 999999.0) |> parse_amount() require Logger Logger.info("MAX_AMOUNT check - Transaction: #{context.amount}, Limit: #{max_amount}, Rule: #{rule.rule_id}") if context.amount > max_amount do {:decline, "Transaction amount #{context.amount} exceeds maximum #{max_amount}"} else :allow end end # TIME_WINDOW rule: Decline if transaction outside allowed time window defp evaluate_time_window(rule, context) do start_time = get_rule_param(rule, "start_time", "00:00") end_time = get_rule_param(rule, "end_time", "23:59") current_time = DateTime.to_time(context.timestamp) case {parse_time(start_time), parse_time(end_time)} do {{:ok, start_t}, {:ok, end_t}} -> if time_in_range?(current_time, start_t, end_t) do :allow else {:decline, "Transaction outside allowed time window #{start_time}-#{end_time}"} end _ -> :allow # If time parsing fails, allow transaction end end # BLACKLIST rule: Decline if merchant/terminal is blacklisted defp evaluate_blacklist(rule, context) do blacklisted_merchants = get_rule_param(rule, "merchants", []) blacklisted_terminals = get_rule_param(rule, "terminals", []) cond do context.merchant_id in blacklisted_merchants -> {:decline, "Merchant #{context.merchant_id} is blacklisted"} context.terminal_id in blacklisted_terminals -> {:decline, "Terminal #{context.terminal_id} is blacklisted"} true -> :allow end end # VELOCITY_COUNT rule: Decline if transaction count exceeds limit within time window defp evaluate_velocity_count(rule, context) do max_count = get_rule_param(rule, "max_count", 999) time_window_minutes = get_rule_param(rule, "time_window_minutes", 10) require Logger Logger.info("VELOCITY_COUNT check - Terminal: #{context.terminal_id}, Max: #{max_count} in #{time_window_minutes} minutes, Transaction timestamp: #{context.timestamp}") # Calculate time threshold (X minutes ago) threshold_time = DateTime.add(context.timestamp, -time_window_minutes * 60, :second) Logger.info("VELOCITY_COUNT threshold_time: #{threshold_time}") # Query transaction history for this terminal in the time window transaction_count = count_recent_transactions(context.terminal_id, threshold_time) Logger.info("VELOCITY_COUNT - Found #{transaction_count} transactions in last #{time_window_minutes} minutes for terminal #{context.terminal_id} (threshold: #{threshold_time})") if transaction_count >= max_count do {:decline, "Transaction count limit exceeded (#{transaction_count}/#{max_count} in #{time_window_minutes} minutes)"} else :allow end rescue e -> require Logger Logger.error("VELOCITY_COUNT check failed: #{inspect(e)}") :allow end # Query database to count recent transactions for a terminal defp count_recent_transactions(terminal_id, since_time) do require Logger # Convert DateTime to naive_datetime for database query since_naive = DateTime.to_naive(since_time) Logger.info("Counting transactions for terminal #{terminal_id} since #{since_naive}") # Query pos_transaction from shukria_prod database using Repo # Note: pos_transaction table uses created_dateTime instead of inserted_at transaction_count = from(t in "pos_transaction", where: t.b_tid == ^terminal_id and t.created_dateTime >= ^since_naive, select: count(t.id) ) |> Repo.one() || 0 Logger.info("count_recent_transactions result: #{transaction_count} (from shukria_prod)") transaction_count end # VELOCITY_AMOUNT rule: Decline if total transaction amount exceeds limit within time window defp evaluate_velocity_amount(rule, context) do max_amount = get_rule_param(rule, "max_amount", 999999.0) |> parse_amount() time_window_minutes = get_rule_param(rule, "time_window_minutes", 10) require Logger Logger.info("VELOCITY_AMOUNT check - Terminal: #{context.terminal_id}, Max: #{max_amount} in #{time_window_minutes} minutes, Transaction timestamp: #{context.timestamp}") # Calculate time threshold (X minutes ago) threshold_time = DateTime.add(context.timestamp, -time_window_minutes * 60, :second) Logger.info("VELOCITY_AMOUNT threshold_time: #{threshold_time}") # Query transaction history for this terminal in the time window total_amount = sum_recent_transaction_amounts(context.terminal_id, threshold_time) Logger.info("VELOCITY_AMOUNT - Found #{total_amount} AED total in last #{time_window_minutes} minutes for terminal #{context.terminal_id} (threshold: #{threshold_time})") if total_amount >= max_amount do {:decline, "Transaction amount limit exceeded (#{total_amount}/#{max_amount} AED in #{time_window_minutes} minutes)"} else :allow end rescue e -> require Logger Logger.error("VELOCITY_AMOUNT check failed: #{inspect(e)}") :allow end # Query database to sum recent transaction amounts for a terminal defp sum_recent_transaction_amounts(terminal_id, since_time) do require Logger # Convert DateTime to naive_datetime for database query since_naive = DateTime.to_naive(since_time) Logger.info("Summing transaction amounts for terminal #{terminal_id} since #{since_naive}") # Query pos_transaction from shukria_prod database using Repo # Note: pos_transaction table uses created_dateTime instead of inserted_at total_amount = from(t in "pos_transaction", where: t.b_tid == ^terminal_id and t.created_dateTime >= ^since_naive, select: sum(t.total_amount) ) |> Repo.one() # Handle NULL case when no transactions found total = case total_amount do nil -> 0.0 amount when is_number(amount) -> amount * 1.0 %Decimal{} = decimal -> Decimal.to_float(decimal) _ -> 0.0 end Logger.info("sum_recent_transaction_amounts result: #{total} (from shukria_prod)") total end # VELOCITY rules (WEEKLY_TOTAL, DAILY_LIMIT, etc.): Check transaction velocity # Note: This is a simplified implementation. Full implementation would query # transaction history from database to check actual velocity. defp evaluate_velocity(_rule, _context) do # For now, we'll allow all transactions and log a warning # A full implementation would: # 1. Query transaction history for the time period # 2. Sum amounts or count transactions # 3. Compare against limits in rule.params # This would require access to transaction history database :allow end # Helper: Get parameter from rule.params (stored as JSON) defp get_rule_param(rule, key, default) do case rule.params do %{^key => value} -> value _ when is_map(rule.params) -> Map.get(rule.params, key, default) _ -> default end end # Helper: Parse time string "HH:MM" to Time struct defp parse_time(time_str) when is_binary(time_str) do case String.split(time_str, ":") do [hour, minute] -> with {h, _} <- Integer.parse(hour), {m, _} <- Integer.parse(minute), {:ok, time} <- Time.new(h, m, 0) do {:ok, time} else _ -> {:error, :invalid_time} end _ -> {:error, :invalid_time} end end defp parse_time(_), do: {:error, :invalid_time} # Helper: Check if time is within range defp time_in_range?(current, start_time, end_time) do if Time.compare(start_time, end_time) == :lt do # Normal range: start < end (e.g., 09:00 - 17:00) Time.compare(current, start_time) in [:gt, :eq] and Time.compare(current, end_time) in [:lt, :eq] else # Wrap-around range: start > end (e.g., 22:00 - 06:00) Time.compare(current, start_time) in [:gt, :eq] or Time.compare(current, end_time) in [:lt, :eq] end end end