defmodule DaProductApp.RiskManagement do @moduledoc """ The Risk Management context for transaction monitoring and rule evaluation. This module provides comprehensive transaction risk management including: - Risk rule evaluation for both POS and QR transactions - Supervisor dashboard for reviewing flagged transactions - Rule engine management for configuring risk rules - Audit logging for supervisor actions """ import Ecto.Query, warn: false alias DaProductApp.Repo alias DaProductApp.RiskManagement.{ RiskRule, RiskRuleHit, SupervisorLastChecked, PosTransaction, RiskAuditLog } alias DaProductApp.Transactions.Transaction alias DaProductApp.Merchants.Merchant # ============================================================================ # Risk Rule Management # ============================================================================ @doc """ Returns the list of risk rules. ## Examples iex> list_risk_rules() [%RiskRule{}, ...] """ def list_risk_rules(opts \\ []) do category = Keyword.get(opts, :category) enabled_only = Keyword.get(opts, :enabled_only, false) RiskRule |> maybe_filter_by_category(category) |> maybe_filter_enabled(enabled_only) |> order_by([r], [asc: r.execution_order, asc: r.name]) |> Repo.all() end defp maybe_filter_by_category(query, nil), do: query defp maybe_filter_by_category(query, category) do where(query, [r], r.category == ^category) end defp maybe_filter_enabled(query, false), do: query defp maybe_filter_enabled(query, true) do where(query, [r], r.enabled == true) end @doc """ Gets a single risk rule. Raises `Ecto.NoResultsError` if the Risk rule does not exist. ## Examples iex> get_risk_rule!(123) %RiskRule{} iex> get_risk_rule!(456) ** (Ecto.NoResultsError) """ def get_risk_rule!(id), do: Repo.get!(RiskRule, id) @doc """ Creates a risk rule. ## Examples iex> create_risk_rule(%{field: value}) {:ok, %RiskRule{}} iex> create_risk_rule(%{field: bad_value}) {:error, %Ecto.Changeset{}} """ def create_risk_rule(attrs \\ %{}, user_id \\ nil) do result = %RiskRule{} |> RiskRule.changeset(attrs) |> Repo.insert() case result do {:ok, rule} -> log_rule_action("rule_created", user_id, rule.id, "create", %{}, Map.take(attrs, [:name, :category, :rule_type, :enabled])) {:ok, rule} error -> error end end @doc """ Updates a risk rule. ## Examples iex> update_risk_rule(risk_rule, %{field: new_value}) {:ok, %RiskRule{}} iex> update_risk_rule(risk_rule, %{field: bad_value}) {:error, %Ecto.Changeset{}} """ def update_risk_rule(%RiskRule{} = risk_rule, attrs, user_id \\ nil) do old_values = %{ name: risk_rule.name, category: risk_rule.category, rule_type: risk_rule.rule_type, enabled: risk_rule.enabled, parameters: risk_rule.parameters } result = risk_rule |> RiskRule.changeset(attrs) |> Repo.update() case result do {:ok, updated_rule} -> new_values = %{ name: updated_rule.name, category: updated_rule.category, rule_type: updated_rule.rule_type, enabled: updated_rule.enabled, parameters: updated_rule.parameters } log_rule_action("rule_updated", user_id, updated_rule.id, "update", old_values, new_values) {:ok, updated_rule} error -> error end end @doc """ Deletes a risk rule. ## Examples iex> delete_risk_rule(risk_rule) {:ok, %RiskRule{}} iex> delete_risk_rule(risk_rule) {:error, %Ecto.Changeset{}} """ def delete_risk_rule(%RiskRule{} = risk_rule, user_id \\ nil) do old_values = %{ name: risk_rule.name, category: risk_rule.category, rule_type: risk_rule.rule_type, enabled: risk_rule.enabled } result = Repo.delete(risk_rule) case result do {:ok, deleted_rule} -> log_rule_action("rule_deleted", user_id, deleted_rule.id, "delete", old_values, %{}) {:ok, deleted_rule} error -> error end end defp log_rule_action(event_type, user_id, rule_id, action, old_values, new_values) do audit_attrs = %{ event_type: event_type, user_id: user_id, resource_type: "risk_rule", resource_id: rule_id, action: action, old_values: old_values, new_values: new_values, metadata: %{ timestamp: DateTime.utc_now(), context: "rule_management" } } %RiskAuditLog{} |> RiskAuditLog.changeset(audit_attrs) |> Repo.insert() end @doc """ Returns an `%Ecto.Changeset{}` for tracking risk rule changes. ## Examples iex> change_risk_rule(risk_rule) %Ecto.Changeset{data: %RiskRule{}} """ def change_risk_rule(%RiskRule{} = risk_rule, attrs \\ %{}) do RiskRule.changeset(risk_rule, attrs) end # ============================================================================ # Risk Rule Evaluation # ============================================================================ @doc """ Evaluates all enabled risk rules for a given transaction. Returns a list of rule hits that should be created. """ def evaluate_transaction_risks(transaction, transaction_type \\ "QR") do # Get merchant category for rule filtering merchant_category = get_merchant_category(transaction.merchant_id) # Get all enabled rules for this category rules = list_risk_rules(category: merchant_category, enabled_only: true) # Evaluate each rule against the transaction Enum.reduce(rules, [], fn rule, hits -> case evaluate_single_rule(rule, transaction, transaction_type) do {:hit, hit_data} -> [hit_data | hits] :no_hit -> hits end end) end defp get_merchant_category(merchant_id) when is_binary(merchant_id) do case Repo.get_by(Merchant, merchant_id: merchant_id) do %Merchant{category: category} when not is_nil(category) -> category _ -> "Cat D" # Default to SME/SMB category end end defp get_merchant_category(_), do: "Cat D" @doc """ Evaluates a single rule against a transaction. Returns {:hit, hit_data} if the rule is triggered, :no_hit otherwise. """ def evaluate_single_rule(%RiskRule{} = rule, transaction, transaction_type) do case rule.name do "Suspicious International transactions" -> evaluate_suspicious_international_rule(rule, transaction, transaction_type) "Multiple Int'l card, low value" -> evaluate_multiple_international_rule(rule, transaction, transaction_type) "Abnormal Time transaction" -> evaluate_abnormal_time_rule(rule, transaction, transaction_type) "Split transaction" -> evaluate_split_transaction_rule(rule, transaction, transaction_type) "Duplicate transaction" -> evaluate_duplicate_transaction_rule(rule, transaction, transaction_type) "High Velocity transaction" -> evaluate_high_velocity_rule(rule, transaction, transaction_type) "High Value Transaction" -> evaluate_high_value_rule(rule, transaction, transaction_type) "Dormant Merchant" -> evaluate_dormant_merchant_rule(rule, transaction, transaction_type) _ -> :no_hit # Rule not implemented yet end end # ============================================================================ # Individual Rule Implementations # ============================================================================ defp evaluate_suspicious_international_rule(rule, transaction, transaction_type) do threshold = get_parameter(rule, "threshold", 10000) amount = get_transaction_amount(transaction) card_type = get_card_type(transaction, transaction_type) if amount >= threshold and card_type == "international" do {:hit, build_rule_hit(rule, transaction, transaction_type, %{ "rule_name" => "Suspicious International transactions", "amount" => amount, "threshold" => threshold, "card_type" => card_type })} else :no_hit end end defp evaluate_high_value_rule(rule, transaction, transaction_type) do threshold = get_parameter(rule, "threshold", 10000) amount = get_transaction_amount(transaction) if amount >= threshold do {:hit, build_rule_hit(rule, transaction, transaction_type, %{ "rule_name" => "High Value Transaction", "amount" => amount, "threshold" => threshold })} else :no_hit end end defp evaluate_abnormal_time_rule(rule, transaction, transaction_type) do excluded_mccs = get_parameter(rule, "excluded_mccs", ["8062", "7011"]) start_hour = get_parameter(rule, "start_hour", 0) end_hour = get_parameter(rule, "end_hour", 7) transaction_time = get_transaction_time(transaction) merchant_mcc = get_merchant_mcc(transaction.merchant_id) hour = transaction_time.hour if hour >= start_hour and hour < end_hour and merchant_mcc not in excluded_mccs do {:hit, build_rule_hit(rule, transaction, transaction_type, %{ "rule_name" => "Abnormal Time transaction", "transaction_hour" => hour, "merchant_mcc" => merchant_mcc })} else :no_hit end end # Placeholder implementations for other rules defp evaluate_multiple_international_rule(rule, transaction, transaction_type) do min_count = get_parameter(rule, "min_count", 2) min_amount = get_parameter(rule, "min_amount", 2000) max_amount = get_parameter(rule, "max_amount", 5000) time_window = get_parameter(rule, "time_window_minutes", 30) amount = get_transaction_amount(transaction) card_type = get_card_type(transaction, transaction_type) # Only evaluate if this is an international card in the specified range if card_type == "international" and amount >= min_amount and amount <= max_amount do # Check for other international transactions in the time window terminal_id = get_terminal_id(transaction, transaction_type) transaction_time = get_transaction_time(transaction) window_start = DateTime.add(transaction_time, -time_window * 60, :second) # Query for similar transactions (this is a simplified version) similar_count = count_similar_transactions(terminal_id, card_type, window_start, transaction_time, transaction_type) if similar_count >= min_count - 1 do # -1 because we include current transaction {:hit, build_rule_hit(rule, transaction, transaction_type, %{ "rule_name" => "Multiple Int'l card, low value", "similar_count" => similar_count + 1, "time_window_minutes" => time_window, "amount" => amount })} else :no_hit end else :no_hit end end defp evaluate_split_transaction_rule(rule, transaction, transaction_type) do time_window = get_parameter(rule, "time_window_minutes", 10) sum_threshold = get_parameter(rule, "sum_threshold", 10000) card_number = get_card_number(transaction, transaction_type) terminal_id = get_terminal_id(transaction, transaction_type) location = get_location(transaction, transaction_type) transaction_time = get_transaction_time(transaction) window_start = DateTime.add(transaction_time, -time_window * 60, :second) # Find transactions with same card, terminal, and location within time window total_amount = calculate_grouped_amount(card_number, terminal_id, location, window_start, transaction_time, transaction_type) if total_amount >= sum_threshold do {:hit, build_rule_hit(rule, transaction, transaction_type, %{ "rule_name" => "Split transaction", "total_amount" => total_amount, "time_window_minutes" => time_window, "grouping" => "#{card_number}|#{terminal_id}|#{location}" })} else :no_hit end end defp evaluate_duplicate_transaction_rule(rule, transaction, transaction_type) do time_window = get_parameter(rule, "time_window_minutes", 10) sum_threshold = get_parameter(rule, "sum_threshold", 10000) card_number = get_card_number(transaction, transaction_type) terminal_id = get_terminal_id(transaction, transaction_type) amount = get_transaction_amount(transaction) transaction_time = get_transaction_time(transaction) window_start = DateTime.add(transaction_time, -time_window * 60, :second) # Find duplicate transactions (same card, terminal, amount) duplicate_count = count_duplicate_transactions(card_number, terminal_id, amount, window_start, transaction_time, transaction_type) total_amount = amount * (duplicate_count + 1) # +1 for current transaction if duplicate_count > 0 and total_amount >= sum_threshold do {:hit, build_rule_hit(rule, transaction, transaction_type, %{ "rule_name" => "Duplicate transaction", "duplicate_count" => duplicate_count + 1, "total_amount" => total_amount, "time_window_minutes" => time_window })} else :no_hit end end defp evaluate_high_velocity_rule(rule, transaction, transaction_type) do time_window = get_parameter(rule, "time_window_minutes", 2) amount_threshold = get_parameter(rule, "amount_threshold", 500) terminal_id = get_terminal_id(transaction, transaction_type) amount = get_transaction_amount(transaction) transaction_time = get_transaction_time(transaction) if amount >= amount_threshold do window_start = DateTime.add(transaction_time, -time_window * 60, :second) # Count transactions on same terminal within time window velocity_count = count_terminal_transactions(terminal_id, window_start, transaction_time, transaction_type) if velocity_count >= 1 do # Current transaction + at least 1 other {:hit, build_rule_hit(rule, transaction, transaction_type, %{ "rule_name" => "High Velocity transaction", "velocity_count" => velocity_count + 1, "time_window_minutes" => time_window, "amount" => amount })} else :no_hit end else :no_hit end end defp evaluate_dormant_merchant_rule(rule, transaction, transaction_type) do inactivity_days = get_parameter(rule, "inactivity_days", 30) merchant_id = transaction.merchant_id transaction_time = get_transaction_time(transaction) cutoff_date = DateTime.add(transaction_time, -inactivity_days * 24 * 3600, :second) # Check if merchant had any transactions before the cutoff date last_activity = get_merchant_last_activity(merchant_id, cutoff_date, transaction_type) if last_activity == nil or DateTime.compare(last_activity, cutoff_date) == :lt do {:hit, build_rule_hit(rule, transaction, transaction_type, %{ "rule_name" => "Dormant Merchant", "inactivity_days" => inactivity_days, "last_activity" => last_activity })} else :no_hit end end # ============================================================================ # Helper Functions # ============================================================================ defp get_parameter(%RiskRule{parameters: params}, key, default) when is_map(params) do Map.get(params, key, default) end defp get_parameter(_, _, default), do: default defp get_transaction_amount(%Transaction{transaction_amount: amount}) when not is_nil(amount), do: Decimal.to_float(amount) defp get_transaction_amount(%Transaction{charge_rate: amount}) when not is_nil(amount), do: Decimal.to_float(amount) defp get_transaction_amount(%PosTransaction{amount: amount}) when not is_nil(amount), do: Decimal.to_float(amount) defp get_transaction_amount(_), do: 0.0 defp get_card_type(%PosTransaction{card_type: card_type}, "POS"), do: card_type || "domestic" defp get_card_type(_, _), do: "domestic" # Default for QR transactions defp get_transaction_time(%Transaction{inserted_at: time}), do: time defp get_transaction_time(%PosTransaction{transaction_time: time}), do: time defp get_merchant_mcc(merchant_id) when is_binary(merchant_id) do case Repo.get_by(Merchant, merchant_id: merchant_id) do %Merchant{} = merchant -> Map.get(merchant, :mcc_code, "0000") _ -> "0000" end end defp get_merchant_mcc(_), do: "0000" defp build_rule_hit(rule, transaction, transaction_type, metadata) do %{ transaction_id: transaction.id, transaction_type: transaction_type, rule_id: rule.id, merchant_id: transaction.merchant_id, category: rule.category, status: if(rule.rule_type == "hold", do: "Hold", else: "Alert"), triggered_at: DateTime.utc_now(), metadata: metadata } end # Additional helper functions for complex rule evaluation defp get_terminal_id(%Transaction{device_id: device_id}, "QR"), do: device_id defp get_terminal_id(%PosTransaction{terminal_id: terminal_id}, "POS"), do: terminal_id defp get_terminal_id(_, _), do: nil defp get_card_number(%PosTransaction{card_number_masked: card_number}, "POS"), do: card_number || "UNKNOWN" defp get_card_number(_, _), do: "QR_TRANSACTION" defp get_location(%Transaction{transaction_location: location}, "QR"), do: location defp get_location(%PosTransaction{location: location}, "POS"), do: location defp get_location(_, _), do: "UNKNOWN" # Simplified database query functions (in production, these would be more sophisticated) defp count_similar_transactions(terminal_id, card_type, window_start, window_end, transaction_type) when not is_nil(terminal_id) do case transaction_type do "POS" -> from(t in PosTransaction, where: t.terminal_id == ^terminal_id and t.card_type == ^card_type and t.transaction_time >= ^window_start and t.transaction_time <= ^window_end, select: count(t.id)) |> Repo.one() || 0 _ -> 0 end end defp count_similar_transactions(_, _, _, _, _), do: 0 defp calculate_grouped_amount(card_number, terminal_id, location, window_start, window_end, transaction_type) when not is_nil(card_number) and not is_nil(terminal_id) do case transaction_type do "POS" -> from(t in PosTransaction, where: t.card_number_masked == ^card_number and t.terminal_id == ^terminal_id and t.location == ^location and t.transaction_time >= ^window_start and t.transaction_time <= ^window_end, select: sum(t.amount)) |> Repo.one() || 0.0 "QR" -> from(t in Transaction, where: t.device_id == ^terminal_id and t.transaction_location == ^location and t.inserted_at >= ^window_start and t.inserted_at <= ^window_end, select: sum(t.transaction_amount)) |> Repo.one() || 0.0 _ -> 0.0 end end defp calculate_grouped_amount(_, _, _, _, _, _), do: 0.0 defp count_duplicate_transactions(card_number, terminal_id, amount, window_start, window_end, transaction_type) do case transaction_type do "POS" -> from(t in PosTransaction, where: t.card_number_masked == ^card_number and t.terminal_id == ^terminal_id and t.amount == ^amount and t.transaction_time >= ^window_start and t.transaction_time <= ^window_end, select: count(t.id)) |> Repo.one() || 0 _ -> 0 end end defp count_terminal_transactions(terminal_id, window_start, window_end, transaction_type) when not is_nil(terminal_id) do case transaction_type do "POS" -> from(t in PosTransaction, where: t.terminal_id == ^terminal_id and t.transaction_time >= ^window_start and t.transaction_time <= ^window_end, select: count(t.id)) |> Repo.one() || 0 "QR" -> from(t in Transaction, where: t.device_id == ^terminal_id and t.inserted_at >= ^window_start and t.inserted_at <= ^window_end, select: count(t.id)) |> Repo.one() || 0 _ -> 0 end end defp count_terminal_transactions(_, _, _, _), do: 0 defp get_merchant_last_activity(merchant_id, cutoff_date, transaction_type) do pos_activity = from(t in PosTransaction, where: t.merchant_id == ^merchant_id and t.transaction_time < ^cutoff_date, select: max(t.transaction_time)) |> Repo.one() qr_activity = from(t in Transaction, where: t.merchant_id == ^merchant_id and t.inserted_at < ^cutoff_date, select: max(t.inserted_at)) |> Repo.one() case {pos_activity, qr_activity} do {nil, nil} -> nil {nil, qr} -> qr {pos, nil} -> pos {pos, qr} -> if DateTime.compare(pos, qr) == :gt, do: pos, else: qr end end # ============================================================================ # Risk Rule Hits Management # ============================================================================ @doc """ Creates a risk rule hit when a rule is triggered. """ def create_risk_rule_hit(attrs \\ %{}) do %RiskRuleHit{} |> RiskRuleHit.changeset(attrs) |> Repo.insert() end @doc """ Gets flagged transactions for a supervisor since their last check. """ def get_new_flagged_transactions(supervisor_id) do last_checked = get_supervisor_last_checked(supervisor_id) from(hit in RiskRuleHit, where: hit.triggered_at > ^last_checked, order_by: [desc: hit.triggered_at], preload: [:rule]) |> Repo.all() end @doc """ Updates the supervisor's last checked timestamp. """ def update_supervisor_last_checked(supervisor_id) do case Repo.get(SupervisorLastChecked, supervisor_id) do nil -> # Insert new record if doesn't exist %SupervisorLastChecked{} |> SupervisorLastChecked.changeset(%{ supervisor_id: supervisor_id, last_checked_at: DateTime.utc_now() }) |> Repo.insert() existing -> # Update existing record existing |> SupervisorLastChecked.changeset(%{ last_checked_at: DateTime.utc_now() }) |> Repo.update() end end defp get_supervisor_last_checked(supervisor_id) do case Repo.get(SupervisorLastChecked, supervisor_id) do %SupervisorLastChecked{last_checked_at: last_checked} -> last_checked nil -> DateTime.add(DateTime.utc_now(), -30, :day) # Default to 30 days ago end end @doc """ Gets the supervisor's last checked timestamp (public version for API). Returns the timestamp for the given supervisor_id. """ def get_supervisor_last_checked_time(supervisor_id) do case Repo.get(SupervisorLastChecked, supervisor_id) do %SupervisorLastChecked{last_checked_at: last_checked} -> last_checked nil -> DateTime.add(DateTime.utc_now(), -30, :day) end end @doc """ Updates a risk rule hit status and logs supervisor action. """ def update_risk_rule_hit_action(hit_id, supervisor_id, action, notes \\ nil, conn \\ nil) do case Repo.get(RiskRuleHit, hit_id) do nil -> {:error, :not_found} hit -> old_values = %{ status: hit.status, action_taken: hit.action_taken, supervisor_id: hit.supervisor_id, notes: hit.notes } # Map action to valid status status = case action do "Released" -> "Released" "Release" -> "Released" "Request Docs" -> "Request Docs" "Reviewed" -> "Reviewed" other -> other end result = hit |> RiskRuleHit.changeset(%{ supervisor_id: supervisor_id, action_taken: action, action_at: DateTime.utc_now(), status: status, notes: notes }) |> Repo.update() case result do {:ok, updated_hit} -> # Log the supervisor action log_supervisor_action(supervisor_id, hit_id, action, old_values, %{ status: updated_hit.status, action_taken: updated_hit.action_taken, supervisor_id: updated_hit.supervisor_id, notes: updated_hit.notes }, notes, conn) {:ok, updated_hit} error -> error end end end defp log_supervisor_action(supervisor_id, hit_id, action, old_values, new_values, notes, conn) do audit_attrs = %{ event_type: "supervisor_action", user_id: supervisor_id, resource_type: "risk_rule_hit", resource_id: hit_id, action: action, old_values: old_values, new_values: new_values, notes: notes, ip_address: get_client_ip(conn), user_agent: get_user_agent(conn), metadata: %{ timestamp: DateTime.utc_now(), action_context: "supervisor_dashboard" } } %RiskAuditLog{} |> RiskAuditLog.changeset(audit_attrs) |> Repo.insert() end defp get_client_ip(nil), do: nil defp get_client_ip(conn) do case Plug.Conn.get_req_header(conn, "x-forwarded-for") do [ip | _] -> ip [] -> to_string(:inet.ntoa(conn.remote_ip)) end end defp get_user_agent(nil), do: nil defp get_user_agent(conn) do case Plug.Conn.get_req_header(conn, "user-agent") do [ua | _] -> ua [] -> nil end end # ============================================================================ # Statistics and Reporting # ============================================================================ @doc """ Gets rule hit statistics for reporting. """ def get_rule_statistics(opts \\ []) do days_back = Keyword.get(opts, :days_back, 30) start_date = DateTime.add(DateTime.utc_now(), -days_back, :day) from(hit in RiskRuleHit, where: hit.triggered_at >= ^start_date, join: rule in assoc(hit, :rule), group_by: [rule.name, rule.category], select: %{ rule_name: rule.name, category: rule.category, hit_count: count(hit.id), hold_count: sum(fragment("CASE WHEN ? = 'Hold' THEN 1 ELSE 0 END", hit.status)), alert_count: sum(fragment("CASE WHEN ? = 'Alert' THEN 1 ELSE 0 END", hit.status)) }, order_by: [desc: count(hit.id)]) |> Repo.all() end # ============================================================================ # Audit Logging and Reporting # ============================================================================ @doc """ Gets audit logs for risk management activities. """ def get_audit_logs(opts \\ []) do days_back = Keyword.get(opts, :days_back, 30) user_id = Keyword.get(opts, :user_id) event_type = Keyword.get(opts, :event_type) limit = Keyword.get(opts, :limit, 100) start_date = DateTime.add(DateTime.utc_now(), -days_back, :day) RiskAuditLog |> where([log], log.inserted_at >= ^start_date) |> maybe_filter_by_user_id(user_id) |> maybe_filter_by_event_type(event_type) |> order_by([log], desc: log.inserted_at) |> limit(^limit) |> Repo.all() end defp maybe_filter_by_user_id(query, nil), do: query defp maybe_filter_by_user_id(query, user_id) do where(query, [log], log.user_id == ^user_id) end defp maybe_filter_by_event_type(query, nil), do: query defp maybe_filter_by_event_type(query, event_type) do where(query, [log], log.event_type == ^event_type) end @doc """ Gets audit summary statistics. """ def get_audit_summary(opts \\ []) do days_back = Keyword.get(opts, :days_back, 30) start_date = DateTime.add(DateTime.utc_now(), -days_back, :day) total_actions = from(log in RiskAuditLog, where: log.inserted_at >= ^start_date, select: count(log.id)) |> Repo.one() supervisor_actions = from(log in RiskAuditLog, where: log.inserted_at >= ^start_date and log.event_type == "supervisor_action", select: count(log.id)) |> Repo.one() rule_changes = from(log in RiskAuditLog, where: log.inserted_at >= ^start_date and log.event_type in ["rule_created", "rule_updated", "rule_deleted"], select: count(log.id)) |> Repo.one() %{ total_actions: total_actions || 0, supervisor_actions: supervisor_actions || 0, rule_changes: rule_changes || 0, period_days: days_back, generated_at: DateTime.utc_now() } end end