use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use stateset_primitives::{FraudRuleId, OrderId};
use strum::{Display, EnumString};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum FraudSignalType {
VelocitySpike,
AddressMismatch,
HighValueFirstOrder,
GeoIpAnomaly,
BinCountryMismatch,
DeviceFingerprint,
ProxyVpn,
DisposableEmail,
PaymentRetries,
UnusualTime,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum FraudDecision {
#[default]
Accept,
Review,
Reject,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FraudSignal {
pub order_id: OrderId,
pub signal_type: FraudSignalType,
pub score: f64,
pub details: String,
pub detected_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FraudAssessment {
pub order_id: OrderId,
pub risk_score: f64,
pub signals: Vec<FraudSignal>,
pub decision: FraudDecision,
pub reviewed_by: Option<String>,
pub review_notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FraudRule {
pub id: FraudRuleId,
pub name: String,
pub description: Option<String>,
pub signal_type: FraudSignalType,
pub threshold: f64,
pub action: FraudDecision,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateFraudAssessment {
pub order_id: OrderId,
pub signals: Vec<CreateFraudSignal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateFraudSignal {
pub signal_type: FraudSignalType,
pub score: f64,
pub details: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateFraudRule {
pub name: String,
pub description: Option<String>,
pub signal_type: FraudSignalType,
pub threshold: f64,
pub action: FraudDecision,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateFraudRule {
pub name: Option<String>,
pub description: Option<Option<String>>,
pub threshold: Option<f64>,
pub action: Option<FraudDecision>,
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FraudAssessmentFilter {
pub decision: Option<FraudDecision>,
pub min_risk_score: Option<f64>,
pub unreviewed_only: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FraudRuleFilter {
pub signal_type: Option<FraudSignalType>,
pub action: Option<FraudDecision>,
pub enabled: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
impl FraudAssessment {
pub fn calculate_risk_score(signals: &[FraudSignal]) -> f64 {
if signals.is_empty() {
return 0.0;
}
signals.iter().map(|s| s.score).fold(0.0_f64, f64::max)
}
pub fn decide(risk_score: f64, rules: &[FraudRule], signals: &[FraudSignal]) -> FraudDecision {
let mut decision = FraudDecision::Accept;
for rule in rules.iter().filter(|r| r.enabled) {
let matching_signal = signals
.iter()
.find(|s| s.signal_type == rule.signal_type && s.score >= rule.threshold);
if matching_signal.is_some() {
match rule.action {
FraudDecision::Reject => return FraudDecision::Reject,
FraudDecision::Review if decision == FraudDecision::Accept => {
decision = FraudDecision::Review;
}
_ => {}
}
}
}
if risk_score >= 0.8 && decision == FraudDecision::Accept {
decision = FraudDecision::Review;
}
decision
}
pub fn needs_review(&self) -> bool {
self.decision == FraudDecision::Review && self.reviewed_by.is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use stateset_primitives::{FraudRuleId, OrderId};
fn make_signal(signal_type: FraudSignalType, score: f64) -> FraudSignal {
FraudSignal {
order_id: OrderId::new(),
signal_type,
score,
details: "test signal".to_string(),
detected_at: Utc::now(),
}
}
fn make_rule(signal_type: FraudSignalType, threshold: f64, action: FraudDecision) -> FraudRule {
FraudRule {
id: FraudRuleId::new(),
name: "test rule".to_string(),
description: None,
signal_type,
threshold,
action,
enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn make_assessment(decision: FraudDecision, reviewed_by: Option<String>) -> FraudAssessment {
FraudAssessment {
order_id: OrderId::new(),
risk_score: 0.5,
signals: vec![],
decision,
reviewed_by,
review_notes: None,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn calculate_risk_score_returns_zero_for_empty_signals() {
assert_eq!(FraudAssessment::calculate_risk_score(&[]), 0.0);
}
#[test]
fn calculate_risk_score_returns_max_signal_score() {
let order_id = OrderId::new();
let signals = vec![
FraudSignal {
order_id,
signal_type: FraudSignalType::VelocitySpike,
score: 0.3,
details: String::new(),
detected_at: Utc::now(),
},
FraudSignal {
order_id,
signal_type: FraudSignalType::AddressMismatch,
score: 0.7,
details: String::new(),
detected_at: Utc::now(),
},
FraudSignal {
order_id,
signal_type: FraudSignalType::GeoIpAnomaly,
score: 0.5,
details: String::new(),
detected_at: Utc::now(),
},
];
assert!((FraudAssessment::calculate_risk_score(&signals) - 0.7).abs() < f64::EPSILON);
}
#[test]
fn decide_returns_accept_with_no_rules() {
let signals = vec![make_signal(FraudSignalType::VelocitySpike, 0.5)];
let decision = FraudAssessment::decide(0.3, &[], &signals);
assert_eq!(decision, FraudDecision::Accept);
}
#[test]
fn decide_returns_reject_when_rule_triggers_reject() {
let signals = vec![make_signal(FraudSignalType::VelocitySpike, 0.9)];
let rules = vec![make_rule(FraudSignalType::VelocitySpike, 0.8, FraudDecision::Reject)];
let decision = FraudAssessment::decide(0.9, &rules, &signals);
assert_eq!(decision, FraudDecision::Reject);
}
#[test]
fn decide_returns_review_when_rule_triggers_review() {
let signals = vec![make_signal(FraudSignalType::AddressMismatch, 0.6)];
let rules = vec![make_rule(FraudSignalType::AddressMismatch, 0.5, FraudDecision::Review)];
let decision = FraudAssessment::decide(0.6, &rules, &signals);
assert_eq!(decision, FraudDecision::Review);
}
#[test]
fn decide_returns_review_on_high_risk_score_fallback() {
let decision = FraudAssessment::decide(0.85, &[], &[]);
assert_eq!(decision, FraudDecision::Review);
}
#[test]
fn decide_disabled_rule_is_ignored() {
let signals = vec![make_signal(FraudSignalType::VelocitySpike, 0.9)];
let mut rule = make_rule(FraudSignalType::VelocitySpike, 0.8, FraudDecision::Reject);
rule.enabled = false;
let decision = FraudAssessment::decide(0.5, &[rule], &signals);
assert_eq!(decision, FraudDecision::Accept);
}
#[test]
fn needs_review_returns_true_when_review_decision_and_no_reviewer() {
let assessment = make_assessment(FraudDecision::Review, None);
assert!(assessment.needs_review());
}
#[test]
fn needs_review_returns_false_when_already_reviewed() {
let assessment = make_assessment(FraudDecision::Review, Some("admin".to_string()));
assert!(!assessment.needs_review());
}
#[test]
fn needs_review_returns_false_when_decision_is_accept() {
let assessment = make_assessment(FraudDecision::Accept, None);
assert!(!assessment.needs_review());
}
#[test]
fn fraud_signal_type_display_fromstr_roundtrip() {
for signal_type in [
FraudSignalType::VelocitySpike,
FraudSignalType::AddressMismatch,
FraudSignalType::HighValueFirstOrder,
FraudSignalType::GeoIpAnomaly,
FraudSignalType::BinCountryMismatch,
FraudSignalType::DeviceFingerprint,
FraudSignalType::ProxyVpn,
FraudSignalType::DisposableEmail,
FraudSignalType::PaymentRetries,
FraudSignalType::UnusualTime,
] {
let s = signal_type.to_string();
let parsed: FraudSignalType = s.parse().unwrap();
assert_eq!(parsed, signal_type, "round-trip failed for {s}");
}
}
#[test]
fn fraud_decision_display_fromstr_roundtrip() {
for decision in [FraudDecision::Accept, FraudDecision::Review, FraudDecision::Reject] {
let s = decision.to_string();
let parsed: FraudDecision = s.parse().unwrap();
assert_eq!(parsed, decision, "round-trip failed for {s}");
}
}
#[test]
fn fraud_decision_default_is_accept() {
assert_eq!(FraudDecision::default(), FraudDecision::Accept);
}
}