| 1 |
|
defmodule WalletRisk.Commands.EscalateCase do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Open a RiskCase escalation for a user whose profile exceeds the review threshold. |
| 4 |
|
|
| 5 |
|
Steps: |
| 6 |
|
1. Fetch or validate RiskProfile for user_id. |
| 7 |
|
2. Build RiskCase with score_at_creation. |
| 8 |
|
3. Store RiskCase in RiskCaseStore. |
| 9 |
|
4. Optionally assign to an investigator. |
| 10 |
|
5. Optionally escalate to compliance if score exceeds block threshold. |
| 11 |
|
6. Emit RiskEscalated event. |
| 12 |
|
7. Emit audit event. |
| 13 |
|
|
| 14 |
|
Returns `{:ok, RiskCase.t()}` or `{:error, reason}`. |
| 15 |
|
""" |
| 16 |
|
|
| 17 |
|
alias WalletRisk.{RiskCase, RiskCaseStore, RiskProfileStore, ScoringEngine} |
| 18 |
|
alias WalletRisk.Events.RiskEscalated |
| 19 |
|
alias WalletObservability.AuditEvent |
| 20 |
|
|
| 21 |
|
@spec execute(user_id :: String.t(), opts :: keyword()) :: |
| 22 |
|
{:ok, RiskCase.t()} | {:error, term()} |
| 23 |
9 |
def execute(user_id, opts \\ []) do |
| 24 |
6 |
correlation_id = |
| 25 |
|
Keyword.get(opts, :correlation_id, WalletSharedKernel.Correlation.new_correlation_id()) |
| 26 |
|
|
| 27 |
6 |
with {:ok, profile} <- get_or_error_profile(user_id) do |
| 28 |
6 |
risk_case = RiskCase.new(user_id, profile.profile_id, profile.score, |
| 29 |
|
correlation_id: correlation_id, |
| 30 |
|
metadata: Keyword.get(opts, :metadata, %{}) |
| 31 |
|
) |
| 32 |
|
|
| 33 |
6 |
risk_case = |
| 34 |
|
if investigator = Keyword.get(opts, :investigator_id) do |
| 35 |
1 |
{:ok, updated} = RiskCase.assign(risk_case, investigator) |
| 36 |
1 |
updated |
| 37 |
|
else |
| 38 |
5 |
risk_case |
| 39 |
|
end |
| 40 |
|
|
| 41 |
6 |
risk_case = |
| 42 |
6 |
if ScoringEngine.exceeds_block_threshold?(profile.score) and |
| 43 |
1 |
Keyword.get(opts, :auto_escalate_to_compliance, false) do |
| 44 |
1 |
reason = Keyword.get(opts, :escalation_reason, "score_exceeded_block_threshold") |
| 45 |
1 |
{:ok, escalated} = RiskCase.escalate_to_compliance(risk_case, reason) |
| 46 |
1 |
escalated |
| 47 |
|
else |
| 48 |
5 |
risk_case |
| 49 |
|
end |
| 50 |
|
|
| 51 |
6 |
with :ok <- RiskCaseStore.store(risk_case) do |
| 52 |
6 |
RiskProfileStore.upsert_for_user(user_id, fn p -> |
| 53 |
6 |
WalletRisk.RiskProfile.add_case(p, risk_case.case_id) |
| 54 |
|
end, []) |
| 55 |
|
|
| 56 |
6 |
emit_event(RiskEscalated.build(risk_case.case_id, |
| 57 |
|
user_id: user_id, |
| 58 |
6 |
profile_id: profile.profile_id, |
| 59 |
6 |
score_at_creation: risk_case.score_at_creation, |
| 60 |
6 |
escalation_reason: risk_case.escalation_reason, |
| 61 |
|
correlation_id: correlation_id |
| 62 |
|
)) |
| 63 |
6 |
emit_audit(risk_case.case_id, user_id, correlation_id, :success) |
| 64 |
|
{:ok, risk_case} |
| 65 |
|
end |
| 66 |
|
end |
| 67 |
|
rescue |
| 68 |
:-( |
error -> {:error, error} |
| 69 |
|
end |
| 70 |
|
|
| 71 |
|
defp get_or_error_profile(user_id) do |
| 72 |
6 |
case WalletRisk.RiskProfileStore.get_by_user(user_id) do |
| 73 |
1 |
{:ok, p} -> {:ok, p} |
| 74 |
|
{:error, :not_found} -> |
| 75 |
|
# Create a baseline profile if none exists |
| 76 |
5 |
profile = WalletRisk.RiskProfile.new(user_id) |
| 77 |
5 |
WalletRisk.RiskProfileStore.store(profile) |
| 78 |
|
{:ok, profile} |
| 79 |
|
end |
| 80 |
|
end |
| 81 |
|
|
| 82 |
6 |
defp emit_event(event) do |
| 83 |
6 |
pubsub = Application.get_env(:wallet_risk, :pubsub, WalletWeb.PubSub) |
| 84 |
6 |
apply(Phoenix.PubSub, :broadcast, [pubsub, "wallet_risk:events", {:domain_event, event}]) |
| 85 |
|
rescue |
| 86 |
:-( |
_ -> :ok |
| 87 |
|
end |
| 88 |
|
|
| 89 |
6 |
defp emit_audit(case_id, user_id, correlation_id, outcome) do |
| 90 |
6 |
audit = AuditEvent.build(:risk, "risk_case_escalated", "risk_case", case_id, outcome, |
| 91 |
|
correlation_id: correlation_id, |
| 92 |
|
metadata: %{user_id: user_id} |
| 93 |
|
) |
| 94 |
6 |
:telemetry.execute([:wallet_risk, :audit], %{}, audit) |
| 95 |
|
rescue |
| 96 |
:-( |
_ -> :ok |
| 97 |
|
end |
| 98 |
|
end |