1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use stateset_primitives::{FraudRuleId, OrderId};
9use strum::{Display, EnumString};
10
11#[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 VelocitySpike,
19 AddressMismatch,
21 HighValueFirstOrder,
23 GeoIpAnomaly,
25 BinCountryMismatch,
27 DeviceFingerprint,
29 ProxyVpn,
31 DisposableEmail,
33 PaymentRetries,
35 UnusualTime,
37}
38
39#[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 #[default]
49 Accept,
50 Review,
52 Reject,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FraudSignal {
59 pub order_id: OrderId,
61 pub signal_type: FraudSignalType,
63 pub score: f64,
65 pub details: String,
67 pub detected_at: DateTime<Utc>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct FraudAssessment {
74 pub order_id: OrderId,
76 pub risk_score: f64,
78 pub signals: Vec<FraudSignal>,
80 pub decision: FraudDecision,
82 pub reviewed_by: Option<String>,
84 pub review_notes: Option<String>,
86 pub created_at: DateTime<Utc>,
88 pub updated_at: DateTime<Utc>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FraudRule {
95 pub id: FraudRuleId,
97 pub name: String,
99 pub description: Option<String>,
101 pub signal_type: FraudSignalType,
103 pub threshold: f64,
105 pub action: FraudDecision,
107 pub enabled: bool,
109 pub created_at: DateTime<Utc>,
111 pub updated_at: DateTime<Utc>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CreateFraudAssessment {
118 pub order_id: OrderId,
120 pub signals: Vec<CreateFraudSignal>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct CreateFraudSignal {
127 pub signal_type: FraudSignalType,
129 pub score: f64,
131 pub details: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct CreateFraudRule {
138 pub name: String,
140 pub description: Option<String>,
142 pub signal_type: FraudSignalType,
144 pub threshold: f64,
146 pub action: FraudDecision,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, Default)]
152pub struct UpdateFraudRule {
153 pub name: Option<String>,
155 pub description: Option<Option<String>>,
157 pub threshold: Option<f64>,
159 pub action: Option<FraudDecision>,
161 pub enabled: Option<bool>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, Default)]
167pub struct FraudAssessmentFilter {
168 pub decision: Option<FraudDecision>,
170 pub min_risk_score: Option<f64>,
172 pub unreviewed_only: Option<bool>,
174 pub limit: Option<u32>,
176 pub offset: Option<u32>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182pub struct FraudRuleFilter {
183 pub signal_type: Option<FraudSignalType>,
185 pub action: Option<FraudDecision>,
187 pub enabled: Option<bool>,
189 pub limit: Option<u32>,
191 pub offset: Option<u32>,
193}
194
195impl FraudAssessment {
196 pub fn calculate_risk_score(signals: &[FraudSignal]) -> f64 {
198 if signals.is_empty() {
199 return 0.0;
200 }
201 signals.iter().map(|s| s.score).fold(0.0_f64, f64::max)
203 }
204
205 #[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 if risk_score >= 0.8 && decision == FraudDecision::Accept {
228 decision = FraudDecision::Review;
229 }
230
231 decision
232 }
233
234 #[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 #[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 #[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 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 assert_eq!(decision, FraudDecision::Accept);
360 }
361
362 #[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 #[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 #[test]
416 fn fraud_decision_default_is_accept() {
417 assert_eq!(FraudDecision::default(), FraudDecision::Accept);
418 }
419}