Skip to main content

stateset_core/models/
fraud.rs

1//! Fraud detection domain models
2//!
3//! Provides signal-based fraud assessment with configurable rules and
4//! manual review workflows.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use stateset_primitives::{FraudRuleId, OrderId};
9use strum::{Display, EnumString};
10
11/// Types of fraud signals that can be detected
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
13#[serde(rename_all = "snake_case")]
14#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
15#[non_exhaustive]
16pub enum FraudSignalType {
17    /// Unusually high order velocity for this customer
18    VelocitySpike,
19    /// Billing and shipping addresses don't match
20    AddressMismatch,
21    /// First order from this customer is unusually high value
22    HighValueFirstOrder,
23    /// Customer IP geolocates to a different country than billing address
24    GeoIpAnomaly,
25    /// Card BIN country doesn't match billing country
26    BinCountryMismatch,
27    /// Known suspicious device fingerprint
28    DeviceFingerprint,
29    /// Connection through proxy or VPN
30    ProxyVpn,
31    /// Email address matches disposable email pattern
32    DisposableEmail,
33    /// Multiple failed payment attempts
34    PaymentRetries,
35    /// Unusual time of day for this customer's locale
36    UnusualTime,
37}
38
39/// Fraud assessment decision
40#[derive(
41    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
42)]
43#[serde(rename_all = "snake_case")]
44#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
45#[non_exhaustive]
46pub enum FraudDecision {
47    /// Order is considered safe, proceed normally
48    #[default]
49    Accept,
50    /// Order needs manual review before proceeding
51    Review,
52    /// Order is considered fraudulent, reject it
53    Reject,
54}
55
56/// A single fraud signal detected for an order
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FraudSignal {
59    /// Order this signal relates to
60    pub order_id: OrderId,
61    /// Type of signal detected
62    pub signal_type: FraudSignalType,
63    /// Confidence score (0.0 = no confidence, 1.0 = certain)
64    pub score: f64,
65    /// Human-readable details about the signal
66    pub details: String,
67    /// When the signal was detected
68    pub detected_at: DateTime<Utc>,
69}
70
71/// Aggregate fraud assessment for an order
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct FraudAssessment {
74    /// Order being assessed
75    pub order_id: OrderId,
76    /// Overall risk score (0.0 = safe, 1.0 = high risk)
77    pub risk_score: f64,
78    /// Individual signals that contributed to the assessment
79    pub signals: Vec<FraudSignal>,
80    /// Final decision
81    pub decision: FraudDecision,
82    /// Who reviewed (None if automated)
83    pub reviewed_by: Option<String>,
84    /// Optional reviewer notes
85    pub review_notes: Option<String>,
86    /// When the assessment was created
87    pub created_at: DateTime<Utc>,
88    /// When the assessment was last updated
89    pub updated_at: DateTime<Utc>,
90}
91
92/// A configurable fraud detection rule
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FraudRule {
95    /// Unique rule ID
96    pub id: FraudRuleId,
97    /// Rule name
98    pub name: String,
99    /// Description of what this rule detects
100    pub description: Option<String>,
101    /// Signal type this rule evaluates
102    pub signal_type: FraudSignalType,
103    /// Score threshold to trigger this rule (0.0-1.0)
104    pub threshold: f64,
105    /// Action to take when rule triggers
106    pub action: FraudDecision,
107    /// Whether this rule is currently active
108    pub enabled: bool,
109    /// When the rule was created
110    pub created_at: DateTime<Utc>,
111    /// When the rule was last updated
112    pub updated_at: DateTime<Utc>,
113}
114
115/// Input for creating a fraud assessment
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CreateFraudAssessment {
118    /// Order to assess
119    pub order_id: OrderId,
120    /// Signals detected
121    pub signals: Vec<CreateFraudSignal>,
122}
123
124/// Input for creating a fraud signal
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct CreateFraudSignal {
127    /// Signal type
128    pub signal_type: FraudSignalType,
129    /// Confidence score
130    pub score: f64,
131    /// Details
132    pub details: String,
133}
134
135/// Input for creating a fraud rule
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct CreateFraudRule {
138    /// Rule name
139    pub name: String,
140    /// Description
141    pub description: Option<String>,
142    /// Signal type
143    pub signal_type: FraudSignalType,
144    /// Threshold
145    pub threshold: f64,
146    /// Action
147    pub action: FraudDecision,
148}
149
150/// Input for updating a fraud rule
151#[derive(Debug, Clone, Serialize, Deserialize, Default)]
152pub struct UpdateFraudRule {
153    /// Updated name
154    pub name: Option<String>,
155    /// Updated description
156    pub description: Option<Option<String>>,
157    /// Updated threshold
158    pub threshold: Option<f64>,
159    /// Updated action
160    pub action: Option<FraudDecision>,
161    /// Updated enabled status
162    pub enabled: Option<bool>,
163}
164
165/// Filter for listing fraud assessments
166#[derive(Debug, Clone, Serialize, Deserialize, Default)]
167pub struct FraudAssessmentFilter {
168    /// Filter by decision
169    pub decision: Option<FraudDecision>,
170    /// Filter by minimum risk score
171    pub min_risk_score: Option<f64>,
172    /// Only unreviewed assessments
173    pub unreviewed_only: Option<bool>,
174    /// Maximum results
175    pub limit: Option<u32>,
176    /// Offset for pagination
177    pub offset: Option<u32>,
178}
179
180/// Filter for listing fraud rules
181#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182pub struct FraudRuleFilter {
183    /// Filter by signal type
184    pub signal_type: Option<FraudSignalType>,
185    /// Filter by action
186    pub action: Option<FraudDecision>,
187    /// Filter by enabled status
188    pub enabled: Option<bool>,
189    /// Maximum results
190    pub limit: Option<u32>,
191    /// Offset for pagination
192    pub offset: Option<u32>,
193}
194
195impl FraudAssessment {
196    /// Calculate the overall risk score from signals
197    pub fn calculate_risk_score(signals: &[FraudSignal]) -> f64 {
198        if signals.is_empty() {
199            return 0.0;
200        }
201        // Use max signal score as the primary risk indicator
202        signals.iter().map(|s| s.score).fold(0.0_f64, f64::max)
203    }
204
205    /// Determine the decision based on risk score and rules
206    #[must_use]
207    pub fn decide(risk_score: f64, rules: &[FraudRule], signals: &[FraudSignal]) -> FraudDecision {
208        let mut decision = FraudDecision::Accept;
209
210        for rule in rules.iter().filter(|r| r.enabled) {
211            let matching_signal = signals
212                .iter()
213                .find(|s| s.signal_type == rule.signal_type && s.score >= rule.threshold);
214
215            if matching_signal.is_some() {
216                match rule.action {
217                    FraudDecision::Reject => return FraudDecision::Reject,
218                    FraudDecision::Review if decision == FraudDecision::Accept => {
219                        decision = FraudDecision::Review;
220                    }
221                    _ => {}
222                }
223            }
224        }
225
226        // Fallback: high risk score triggers review
227        if risk_score >= 0.8 && decision == FraudDecision::Accept {
228            decision = FraudDecision::Review;
229        }
230
231        decision
232    }
233
234    /// Whether this assessment needs human review
235    #[must_use]
236    pub fn needs_review(&self) -> bool {
237        self.decision == FraudDecision::Review && self.reviewed_by.is_none()
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use chrono::Utc;
245    use stateset_primitives::{FraudRuleId, OrderId};
246
247    fn make_signal(signal_type: FraudSignalType, score: f64) -> FraudSignal {
248        FraudSignal {
249            order_id: OrderId::new(),
250            signal_type,
251            score,
252            details: "test signal".to_string(),
253            detected_at: Utc::now(),
254        }
255    }
256
257    fn make_rule(signal_type: FraudSignalType, threshold: f64, action: FraudDecision) -> FraudRule {
258        FraudRule {
259            id: FraudRuleId::new(),
260            name: "test rule".to_string(),
261            description: None,
262            signal_type,
263            threshold,
264            action,
265            enabled: true,
266            created_at: Utc::now(),
267            updated_at: Utc::now(),
268        }
269    }
270
271    fn make_assessment(decision: FraudDecision, reviewed_by: Option<String>) -> FraudAssessment {
272        FraudAssessment {
273            order_id: OrderId::new(),
274            risk_score: 0.5,
275            signals: vec![],
276            decision,
277            reviewed_by,
278            review_notes: None,
279            created_at: Utc::now(),
280            updated_at: Utc::now(),
281        }
282    }
283
284    // ---- calculate_risk_score ----
285
286    #[test]
287    fn calculate_risk_score_returns_zero_for_empty_signals() {
288        assert_eq!(FraudAssessment::calculate_risk_score(&[]), 0.0);
289    }
290
291    #[test]
292    fn calculate_risk_score_returns_max_signal_score() {
293        let order_id = OrderId::new();
294        let signals = vec![
295            FraudSignal {
296                order_id,
297                signal_type: FraudSignalType::VelocitySpike,
298                score: 0.3,
299                details: String::new(),
300                detected_at: Utc::now(),
301            },
302            FraudSignal {
303                order_id,
304                signal_type: FraudSignalType::AddressMismatch,
305                score: 0.7,
306                details: String::new(),
307                detected_at: Utc::now(),
308            },
309            FraudSignal {
310                order_id,
311                signal_type: FraudSignalType::GeoIpAnomaly,
312                score: 0.5,
313                details: String::new(),
314                detected_at: Utc::now(),
315            },
316        ];
317        assert!((FraudAssessment::calculate_risk_score(&signals) - 0.7).abs() < f64::EPSILON);
318    }
319
320    // ---- decide ----
321
322    #[test]
323    fn decide_returns_accept_with_no_rules() {
324        let signals = vec![make_signal(FraudSignalType::VelocitySpike, 0.5)];
325        let decision = FraudAssessment::decide(0.3, &[], &signals);
326        assert_eq!(decision, FraudDecision::Accept);
327    }
328
329    #[test]
330    fn decide_returns_reject_when_rule_triggers_reject() {
331        let signals = vec![make_signal(FraudSignalType::VelocitySpike, 0.9)];
332        let rules = vec![make_rule(FraudSignalType::VelocitySpike, 0.8, FraudDecision::Reject)];
333        let decision = FraudAssessment::decide(0.9, &rules, &signals);
334        assert_eq!(decision, FraudDecision::Reject);
335    }
336
337    #[test]
338    fn decide_returns_review_when_rule_triggers_review() {
339        let signals = vec![make_signal(FraudSignalType::AddressMismatch, 0.6)];
340        let rules = vec![make_rule(FraudSignalType::AddressMismatch, 0.5, FraudDecision::Review)];
341        let decision = FraudAssessment::decide(0.6, &rules, &signals);
342        assert_eq!(decision, FraudDecision::Review);
343    }
344
345    #[test]
346    fn decide_returns_review_on_high_risk_score_fallback() {
347        // No rules match, but risk_score >= 0.8 triggers review fallback
348        let decision = FraudAssessment::decide(0.85, &[], &[]);
349        assert_eq!(decision, FraudDecision::Review);
350    }
351
352    #[test]
353    fn decide_disabled_rule_is_ignored() {
354        let signals = vec![make_signal(FraudSignalType::VelocitySpike, 0.9)];
355        let mut rule = make_rule(FraudSignalType::VelocitySpike, 0.8, FraudDecision::Reject);
356        rule.enabled = false;
357        let decision = FraudAssessment::decide(0.5, &[rule], &signals);
358        // Disabled rule should not trigger; risk_score < 0.8 so no fallback review
359        assert_eq!(decision, FraudDecision::Accept);
360    }
361
362    // ---- needs_review ----
363
364    #[test]
365    fn needs_review_returns_true_when_review_decision_and_no_reviewer() {
366        let assessment = make_assessment(FraudDecision::Review, None);
367        assert!(assessment.needs_review());
368    }
369
370    #[test]
371    fn needs_review_returns_false_when_already_reviewed() {
372        let assessment = make_assessment(FraudDecision::Review, Some("admin".to_string()));
373        assert!(!assessment.needs_review());
374    }
375
376    #[test]
377    fn needs_review_returns_false_when_decision_is_accept() {
378        let assessment = make_assessment(FraudDecision::Accept, None);
379        assert!(!assessment.needs_review());
380    }
381
382    // ---- enum Display / FromStr round-trips ----
383
384    #[test]
385    fn fraud_signal_type_display_fromstr_roundtrip() {
386        for signal_type in [
387            FraudSignalType::VelocitySpike,
388            FraudSignalType::AddressMismatch,
389            FraudSignalType::HighValueFirstOrder,
390            FraudSignalType::GeoIpAnomaly,
391            FraudSignalType::BinCountryMismatch,
392            FraudSignalType::DeviceFingerprint,
393            FraudSignalType::ProxyVpn,
394            FraudSignalType::DisposableEmail,
395            FraudSignalType::PaymentRetries,
396            FraudSignalType::UnusualTime,
397        ] {
398            let s = signal_type.to_string();
399            let parsed: FraudSignalType = s.parse().unwrap();
400            assert_eq!(parsed, signal_type, "round-trip failed for {s}");
401        }
402    }
403
404    #[test]
405    fn fraud_decision_display_fromstr_roundtrip() {
406        for decision in [FraudDecision::Accept, FraudDecision::Review, FraudDecision::Reject] {
407            let s = decision.to_string();
408            let parsed: FraudDecision = s.parse().unwrap();
409            assert_eq!(parsed, decision, "round-trip failed for {s}");
410        }
411    }
412
413    // ---- Defaults ----
414
415    #[test]
416    fn fraud_decision_default_is_accept() {
417        assert_eq!(FraudDecision::default(), FraudDecision::Accept);
418    }
419}