From 287a67e275a3259d86104e08d1d7f751375cf251 Mon Sep 17 00:00:00 2001 From: barry01_hash Date: Wed, 27 May 2026 13:07:18 +0100 Subject: [PATCH] fraud detection workflow --- contracts/fraud/src/lib.rs | 274 +++++++++++- contracts/types/src/lib.rs | 20 + src/screens/FraudDashboard.tsx | 173 +++++++- src/store/__tests__/fraudStore.test.ts | 64 +++ src/store/fraudStore.ts | 554 +++++++++++++++++++++---- src/types/fraud.ts | 38 +- 6 files changed, 998 insertions(+), 125 deletions(-) diff --git a/contracts/fraud/src/lib.rs b/contracts/fraud/src/lib.rs index 125c689..447d249 100644 --- a/contracts/fraud/src/lib.rs +++ b/contracts/fraud/src/lib.rs @@ -23,6 +23,11 @@ struct SubscriptionProfile { expected_usage: u32, observed_usage: u32, chargebacks: u32, + home_country: Option, + current_country: Option, + device_fingerprint: Option, + trusted_device_fingerprint: Option, + false_positive_count: u32, is_flagged: bool, is_blocked: bool, } @@ -73,15 +78,17 @@ fn get_merchant_subscriptions(env: &Env, merchant: &Address) -> Vec) { - env.storage() - .persistent() - .set(&StorageKey::SubscriberSubscriptions(subscriber.clone()), &ids.clone()); + env.storage().persistent().set( + &StorageKey::SubscriberSubscriptions(subscriber.clone()), + &ids.clone(), + ); } fn save_merchant_subscriptions(env: &Env, merchant: &Address, ids: &Vec) { - env.storage() - .persistent() - .set(&StorageKey::MerchantSubscriptions(merchant.clone()), &ids.clone()); + env.storage().persistent().set( + &StorageKey::MerchantSubscriptions(merchant.clone()), + &ids.clone(), + ); } fn load_profile(env: &Env, subscription_id: SubscriptionId) -> Option { @@ -91,12 +98,17 @@ fn load_profile(env: &Env, subscription_id: SubscriptionId) -> Option) -> u32 { +fn determine_velocity_score( + env: &Env, + profile: &SubscriptionProfile, + ids: &Vec, +) -> u32 { let now = env.ledger().timestamp(); let mut recent_creations = 0u32; let mut i = 0u32; @@ -149,6 +161,39 @@ fn determine_chargeback_score(profile: &SubscriptionProfile) -> u32 { } } +fn determine_geolocation_score(profile: &SubscriptionProfile) -> u32 { + match (&profile.home_country, &profile.current_country) { + (Some(home), Some(current)) if home != current => 24, + _ => 0, + } +} + +fn determine_device_score(profile: &SubscriptionProfile) -> u32 { + match ( + &profile.device_fingerprint, + &profile.trusted_device_fingerprint, + ) { + (Some(current), Some(trusted)) if current != trusted => 20, + _ => 0, + } +} + +fn determine_pattern_shift_score( + profile: &SubscriptionProfile, + geo_score: u32, + device_score: u32, +) -> u32 { + if profile.observed_usage >= profile.expected_usage.saturating_mul(2) + && (geo_score > 0 || device_score > 0) + { + 16 + } else if profile.observed_usage > profile.expected_usage && profile.chargebacks > 0 { + 12 + } else { + 0 + } +} + fn determine_action(score: u32) -> FraudAction { if score >= HIGH_RISK_THRESHOLD { FraudAction::Block @@ -159,16 +204,75 @@ fn determine_action(score: u32) -> FraudAction { } } -fn score_profile( +fn build_evidence( env: &Env, profile: &SubscriptionProfile, - ids: &Vec, -) -> RiskScore { + geo_score: u32, + device_score: u32, +) -> Vec { + let now = env.ledger().timestamp(); + let mut evidence = Vec::new(env); + evidence.push_back(subtrackr_types::FraudEvidence { + label: String::from_str(env, "payment profile"), + value: String::from_str(env, "subscription payment reviewed"), + source: String::from_str(env, "payment"), + captured_at: now, + confidence: 88, + }); + + if geo_score > 0 { + let current = profile + .current_country + .clone() + .unwrap_or_else(|| String::from_str(env, "unknown")); + evidence.push_back(subtrackr_types::FraudEvidence { + label: String::from_str(env, "location drift"), + value: current, + source: String::from_str(env, "location"), + captured_at: now, + confidence: 92, + }); + } + + if device_score > 0 { + let current = profile + .device_fingerprint + .clone() + .unwrap_or_else(|| String::from_str(env, "unknown")); + let trusted = profile + .trusted_device_fingerprint + .clone() + .unwrap_or_else(|| String::from_str(env, "unknown")); + evidence.push_back(subtrackr_types::FraudEvidence { + label: String::from_str(env, "device mismatch"), + value: current, + source: String::from_str(env, "device"), + captured_at: now, + confidence: 87, + }); + } + + evidence +} + +fn score_profile(env: &Env, profile: &SubscriptionProfile, ids: &Vec) -> RiskScore { let now = env.ledger().timestamp(); let velocity_score = determine_velocity_score(env, profile, ids); let anomaly_score = determine_anomaly_score(profile); let chargeback_score = determine_chargeback_score(profile); - let total_score = (velocity_score + anomaly_score + chargeback_score).min(100); + let geolocation_score = determine_geolocation_score(profile); + let device_mismatch_score = determine_device_score(profile); + let pattern_shift_score = + determine_pattern_shift_score(profile, geolocation_score, device_mismatch_score); + let false_positive_penalty = (profile.false_positive_count * 20).min(60); + let total_score = (velocity_score + + anomaly_score + + chargeback_score + + geolocation_score + + device_mismatch_score + + pattern_shift_score) + .saturating_sub(false_positive_penalty) + .min(100); let mut signals = Vec::new(env); if velocity_score > 0 { @@ -195,8 +299,39 @@ fn score_profile( observed_at: now, }); } + if geolocation_score > 0 { + signals.push_back(RiskSignal { + kind: RiskSignalKind::GeolocationAnomaly, + score: geolocation_score, + detail: String::from_str(env, "current location differs from the usual profile"), + observed_at: now, + }); + } + if device_mismatch_score > 0 { + signals.push_back(RiskSignal { + kind: RiskSignalKind::DeviceMismatch, + score: device_mismatch_score, + detail: String::from_str(env, "device fingerprint does not match the trusted profile"), + observed_at: now, + }); + } + if pattern_shift_score > 0 { + signals.push_back(RiskSignal { + kind: RiskSignalKind::PatternShift, + score: pattern_shift_score, + detail: String::from_str(env, "usage patterns shifted alongside fraud indicators"), + observed_at: now, + }); + } - let reason = if chargeback_score >= anomaly_score && chargeback_score >= velocity_score { + let reason = if geolocation_score >= chargeback_score + && geolocation_score >= anomaly_score + && geolocation_score >= velocity_score + { + String::from_str(env, "geolocation anomaly is the dominant signal") + } else if device_mismatch_score >= chargeback_score && device_mismatch_score >= anomaly_score { + String::from_str(env, "device mismatch is the dominant signal") + } else if chargeback_score >= anomaly_score && chargeback_score >= velocity_score { String::from_str(env, "chargeback risk dominates") } else if velocity_score >= anomaly_score { String::from_str(env, "velocity risk is elevated") @@ -212,10 +347,14 @@ fn score_profile( velocity_score, anomaly_score, chargeback_score, + device_mismatch_score, + geolocation_score, + pattern_shift_score, action: determine_action(total_score), reason, assessed_at: now, signals, + evidence: build_evidence(env, profile, geolocation_score, device_mismatch_score), } } @@ -231,11 +370,14 @@ fn persist_case(env: &Env, score: &RiskScore, status: FraudReviewStatus) -> Frau reason: score.reason.clone(), created_at: score.assessed_at, updated_at: score.assessed_at, + evidence: score.evidence.clone(), + reviewed_at: score.assessed_at, }; - env.storage() - .persistent() - .set(&StorageKey::ReviewCase(score.subscription_id), &case.clone()); + env.storage().persistent().set( + &StorageKey::ReviewCase(score.subscription_id), + &case.clone(), + ); case } @@ -277,6 +419,11 @@ impl SubTrackrFraud { expected_usage: 1, observed_usage: 1, chargebacks: 0, + home_country: Option::None, + current_country: Option::None, + device_fingerprint: Option::None, + trusted_device_fingerprint: Option::None, + false_positive_count: 0, is_flagged: false, is_blocked: false, }; @@ -309,15 +456,45 @@ impl SubTrackrFraud { } } - pub fn record_chargeback( + pub fn record_chargeback(env: Env, subscriber: Address, subscription_id: SubscriptionId) { + subscriber.require_auth(); + + if let Some(mut profile) = load_profile(&env, subscription_id) { + profile.chargebacks = profile.chargebacks.saturating_add(1); + profile.last_activity_at = env.ledger().timestamp(); + save_profile(&env, &profile); + } + } + + pub fn record_location_profile( env: Env, subscriber: Address, subscription_id: SubscriptionId, + home_country: String, + current_country: String, ) { subscriber.require_auth(); if let Some(mut profile) = load_profile(&env, subscription_id) { - profile.chargebacks = profile.chargebacks.saturating_add(1); + profile.home_country = Option::Some(home_country); + profile.current_country = Option::Some(current_country); + profile.last_activity_at = env.ledger().timestamp(); + save_profile(&env, &profile); + } + } + + pub fn record_device_profile( + env: Env, + subscriber: Address, + subscription_id: SubscriptionId, + device_fingerprint: String, + trusted_device_fingerprint: String, + ) { + subscriber.require_auth(); + + if let Some(mut profile) = load_profile(&env, subscription_id) { + profile.device_fingerprint = Option::Some(device_fingerprint); + profile.trusted_device_fingerprint = Option::Some(trusted_device_fingerprint); profile.last_activity_at = env.ledger().timestamp(); save_profile(&env, &profile); } @@ -334,10 +511,14 @@ impl SubTrackrFraud { velocity_score: 0, anomaly_score: 0, chargeback_score: 0, + device_mismatch_score: 0, + geolocation_score: 0, + pattern_shift_score: 0, action: FraudAction::Approve, reason: String::from_str(&env, "no subscription history"), assessed_at: env.ledger().timestamp(), signals: Vec::new(&env), + evidence: Vec::new(&env), }; } @@ -373,7 +554,10 @@ impl SubTrackrFraud { let case = persist_case(&env, &score, status); update_profile_action(&env, subscription_id, &score); env.events().publish( - (String::from_str(&env, "fraud_case_opened"), score.subscription_id), + ( + String::from_str(&env, "fraud_case_opened"), + score.subscription_id, + ), (case.risk_score, case.action.clone()), ); } else { @@ -391,6 +575,7 @@ impl SubTrackrFraud { if let Some(mut case) = review_case_for_subscription(&env, subscription_id) { case.status = FraudReviewStatus::Reviewed; case.updated_at = env.ledger().timestamp(); + case.reviewed_at = case.updated_at; case.action = if approved { FraudAction::Approve } else { @@ -402,6 +587,32 @@ impl SubTrackrFraud { } } + pub fn submit_false_positive_feedback( + env: Env, + subscriber: Address, + subscription_id: SubscriptionId, + ) { + subscriber.require_auth(); + + if let Some(mut profile) = load_profile(&env, subscription_id) { + profile.false_positive_count = profile.false_positive_count.saturating_add(1); + profile.is_flagged = false; + profile.is_blocked = false; + profile.last_activity_at = env.ledger().timestamp(); + save_profile(&env, &profile); + } + + if let Some(mut case) = review_case_for_subscription(&env, subscription_id) { + case.status = FraudReviewStatus::Dismissed; + case.updated_at = env.ledger().timestamp(); + case.reviewed_at = case.updated_at; + case.action = FraudAction::Approve; + env.storage() + .persistent() + .set(&StorageKey::ReviewCase(subscription_id), &case.clone()); + } + } + pub fn get_fraud_report(env: Env, merchant_id: Address) -> FraudReport { let ids = get_merchant_subscriptions(&env, &merchant_id); let mut total_risk = 0u32; @@ -410,7 +621,10 @@ impl SubTrackrFraud { let mut manual_review = 0u32; let mut velocity_alerts = 0u32; let mut anomaly_alerts = 0u32; + let mut geolocation_alerts = 0u32; let mut chargeback_predictions = 0u32; + let mut pending_evidence = 0u32; + let mut false_positive_feedback = 0u32; let mut high_risk_subscribers: Vec
= Vec::new(&env); let mut recent_cases: Vec = Vec::new(&env); @@ -436,6 +650,9 @@ impl SubTrackrFraud { if score.anomaly_score > 0 { anomaly_alerts += 1; } + if score.geolocation_score > 0 { + geolocation_alerts += 1; + } if score.chargeback_score > 0 { chargeback_predictions += 1; } @@ -444,9 +661,19 @@ impl SubTrackrFraud { } if let Some(case) = review_case_for_subscription(&env, score.subscription_id) { + if case.evidence.len() == 0 { + pending_evidence += 1; + } + if case.status == FraudReviewStatus::Dismissed { + false_positive_feedback += 1; + } recent_cases.push_back(case); } else if score.total_score >= REVIEW_THRESHOLD { - recent_cases.push_back(persist_case(&env, &score, FraudReviewStatus::Pending)); + let case = persist_case(&env, &score, FraudReviewStatus::Pending); + if case.evidence.len() == 0 { + pending_evidence += 1; + } + recent_cases.push_back(case); } } i += 1; @@ -478,8 +705,11 @@ impl SubTrackrFraud { average_risk, velocity_alerts, anomaly_alerts, + geolocation_alerts, chargeback_predictions, high_risk_subscribers: high_risk_subscribers.len(), + pending_evidence_count: pending_evidence, + false_positive_feedback_count: false_positive_feedback, recent_cases: trimmed_cases, } } diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 86daa83..2c50a85 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -229,6 +229,7 @@ pub enum RiskSignalKind { Chargeback, PatternShift, DeviceMismatch, + GeolocationAnomaly, } #[contracttype] @@ -240,6 +241,16 @@ pub struct RiskSignal { pub observed_at: Timestamp, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct FraudEvidence { + pub label: String, + pub value: String, + pub source: String, + pub captured_at: Timestamp, + pub confidence: u32, +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct RiskScore { @@ -250,10 +261,14 @@ pub struct RiskScore { pub velocity_score: u32, pub anomaly_score: u32, pub chargeback_score: u32, + pub device_mismatch_score: u32, + pub geolocation_score: u32, + pub pattern_shift_score: u32, pub action: FraudAction, pub reason: String, pub assessed_at: Timestamp, pub signals: Vec, + pub evidence: Vec, } #[contracttype] @@ -269,6 +284,8 @@ pub struct FraudCase { pub reason: String, pub created_at: Timestamp, pub updated_at: Timestamp, + pub evidence: Vec, + pub reviewed_at: Timestamp, } #[contracttype] @@ -282,8 +299,11 @@ pub struct FraudReport { pub average_risk: u32, pub velocity_alerts: u32, pub anomaly_alerts: u32, + pub geolocation_alerts: u32, pub chargeback_predictions: u32, pub high_risk_subscribers: u32, + pub pending_evidence_count: u32, + pub false_positive_feedback_count: u32, pub recent_cases: Vec, } diff --git a/src/screens/FraudDashboard.tsx b/src/screens/FraudDashboard.tsx index f26dd4b..51f3063 100644 --- a/src/screens/FraudDashboard.tsx +++ b/src/screens/FraudDashboard.tsx @@ -34,6 +34,7 @@ const FraudDashboard: React.FC = () => { approveSubscription, blockSubscription, resolveCase, + submitFalsePositiveFeedback, getFraudReport, } = useFraudStore(); @@ -63,8 +64,8 @@ const FraudDashboard: React.FC = () => { Fraud Control Center - Risk scoring, velocity checks, usage anomaly detection, chargeback prediction, and a - manual review queue for subscription operations. + Risk scoring, velocity checks, geolocation anomaly detection, chargeback prediction, + and a manual review queue with evidence-backed decisions. @@ -73,15 +74,46 @@ const FraudDashboard: React.FC = () => { - {renderMetric('Total checks', analytics.totalChecks.toString(), 'Subscriptions reviewed', colors.accent)} - {renderMetric('Blocked', analytics.blocked.toString(), 'Automated hard stops', colors.error)} - {renderMetric('Flagged', analytics.flagged.toString(), 'Queued for review', colors.warning)} + {renderMetric( + 'Total checks', + analytics.totalChecks.toString(), + 'Subscriptions reviewed', + colors.accent + )} + {renderMetric( + 'Blocked', + analytics.blocked.toString(), + 'Automated hard stops', + colors.error + )} + {renderMetric( + 'Flagged', + analytics.flagged.toString(), + 'Queued for review', + colors.warning + )} {renderMetric('Avg risk', `${analytics.avgRisk}`, 'Aggregate risk score', colors.primary)} - {renderMetric('Velocity alerts', analytics.velocityAlerts.toString(), 'Rapid creation detected', colors.secondary)} - {renderMetric('Anomaly alerts', analytics.anomalyAlerts.toString(), 'Usage deviates from baseline', colors.accent)} + {renderMetric( + 'Velocity alerts', + analytics.velocityAlerts.toString(), + 'Rapid creation detected', + colors.secondary + )} + {renderMetric( + 'Anomaly alerts', + analytics.anomalyAlerts.toString(), + 'Usage deviates from baseline', + colors.accent + )} + {renderMetric( + 'Geo alerts', + analytics.geoAnomalyAlerts.toString(), + 'Location drift and travel anomalies', + colors.warning + )} {renderMetric( 'Chargeback predictions', analytics.chargebackPredictions.toString(), @@ -89,18 +121,26 @@ const FraudDashboard: React.FC = () => { colors.error )} {renderMetric( - 'False positives', - analytics.falsePositiveEstimate.toString(), - 'Estimated manual override rate', + 'False positive rate', + `${analytics.falsePositiveRate}%`, + 'Feedback loop for model tuning', colors.success )} + {renderMetric( + 'Model confidence', + `${analytics.modelConfidence}%`, + 'Adjusted for false-positive feedback', + colors.primary + )} Manual review queue - {reviewQueue.length} open cases + + {reviewQueue.length} open cases · {analytics.manualReviewsClosed} closed + {reviewQueue.map((item) => ( @@ -118,22 +158,61 @@ const FraudDashboard: React.FC = () => { Risk {item.riskScore} + {item.outcome ? ( + + {item.outcome.replace('_', ' ')} + + ) : null} + {item.evidence?.length ? ( + + Evidence + + {item.evidence.map((evidence) => ( + + + {evidence.label}: {evidence.value} + + + ))} + + + ) : ( + No evidence attached yet. + )} - approveSubscription(item.subscriptionId)}> + approveSubscription(item.subscriptionId)}> Approve - resolveCase(item.subscriptionId, 'flag')}> + resolveCase(item.subscriptionId, 'flag')}> Flag - blockSubscription(item.subscriptionId)}> + blockSubscription(item.subscriptionId)}> Block + + submitFalsePositiveFeedback( + item.subscriptionId, + 'Reviewer marked as false positive' + ) + }> + False positive + ))} - {reviewQueue.length === 0 ? No cases awaiting manual review. : null} + {reviewQueue.length === 0 ? ( + No cases awaiting manual review. + ) : null} @@ -181,13 +260,16 @@ const FraudDashboard: React.FC = () => { {report.averageRisk} - {report.totalSubscriptions} subs · {report.flaggedSubscriptions} flagged · {report.blockedSubscriptions} blocked + {report.totalSubscriptions} subs · {report.flaggedSubscriptions} flagged ·{' '} + {report.blockedSubscriptions} blocked Manual review {report.manualReviewCount} Velocity {report.velocityAlerts} Anomaly {report.anomalyAlerts} Chargeback {report.chargebackPredictions} + Geo {report.geolocationAlerts} + Evidence {report.pendingEvidenceCount} ))} @@ -201,21 +283,45 @@ const FraudDashboard: React.FC = () => { Approved - + {analytics.approved} Flagged - + {analytics.flagged} Blocked - + {analytics.blocked} @@ -420,6 +526,12 @@ const styles = StyleSheet.create({ borderRadius: borderRadius.md, backgroundColor: colors.error, }, + caseButtonSecondary: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + backgroundColor: colors.secondary, + }, caseButtonText: { ...typography.caption, color: colors.text, @@ -458,6 +570,29 @@ const styles = StyleSheet.create({ ...typography.caption, color: colors.textSecondary, }, + evidenceBlock: { + marginTop: spacing.sm, + gap: spacing.xs, + }, + evidenceLabel: { + ...typography.caption, + color: colors.accent, + fontWeight: '700', + textTransform: 'uppercase', + }, + evidenceChip: { + paddingVertical: 4, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.full, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + evidenceChipText: { + ...typography.caption, + color: colors.textSecondary, + fontWeight: '600', + }, scoreText: { ...typography.h3, color: colors.text, diff --git a/src/store/__tests__/fraudStore.test.ts b/src/store/__tests__/fraudStore.test.ts index 54c3c98..929cceb 100644 --- a/src/store/__tests__/fraudStore.test.ts +++ b/src/store/__tests__/fraudStore.test.ts @@ -21,11 +21,15 @@ describe('fraudStore', () => { flagged: 0, blocked: 0, manualReviews: 0, + manualReviewsClosed: 0, avgRisk: 0, velocityAlerts: 0, anomalyAlerts: 0, + geoAnomalyAlerts: 0, chargebackPredictions: 0, falsePositiveEstimate: 0, + falsePositiveRate: 0, + modelConfidence: 0, }, loading: false, error: null, @@ -138,4 +142,64 @@ describe('fraudStore', () => { expect(useFraudStore.getState().subscriptions[0].action).toBe('approve'); expect(useFraudStore.getState().reviewQueue[0].status).toBe('reviewed'); }); + + it('records false positive feedback and lowers future risk', () => { + useFraudStore.setState({ + subscriptions: [ + { + id: 's3', + merchantId: 'm3', + merchantName: 'SignalCo', + subscriberId: 'sub-3', + subscriptionName: 'Plan C', + currency: 'USD', + amount: 55, + createdAt: '2026-04-24T09:00:00.000Z', + expectedUsage: 3, + observedUsage: 15, + chargebacks: 1, + homeCountry: 'US', + currentCountry: 'CA', + deviceFingerprint: 'device-old', + trustedDeviceFingerprint: 'device-new', + riskScore: 88, + action: 'block', + reason: 'high risk', + usagePattern: 'burst', + signals: [], + isBlocked: true, + isFlagged: true, + falsePositiveCount: 0, + }, + ], + reviewQueue: [ + { + caseId: 's3', + subscriptionId: 's3', + subscriberId: 'sub-3', + merchantId: 'm3', + merchantName: 'SignalCo', + subscriptionName: 'Plan C', + riskScore: 88, + action: 'block', + status: 'pending', + reason: 'high risk', + createdAt: '2026-04-24T09:00:00.000Z', + updatedAt: '2026-04-24T09:00:00.000Z', + evidence: [], + }, + ], + }); + + act(() => { + useFraudStore.getState().submitFalsePositiveFeedback('s3', 'Legit travel'); + useFraudStore.getState().refreshFraudSignals(); + }); + + const store = useFraudStore.getState(); + expect(store.subscriptions[0].falsePositiveCount).toBe(1); + expect(store.subscriptions[0].riskScore).toBeLessThan(100); + expect(store.reviewQueue[0].status).toBe('dismissed'); + expect(store.analytics.falsePositiveRate).toBeGreaterThanOrEqual(0); + }); }); diff --git a/src/store/fraudStore.ts b/src/store/fraudStore.ts index a4f281a..15484ee 100644 --- a/src/store/fraudStore.ts +++ b/src/store/fraudStore.ts @@ -5,6 +5,7 @@ import { FraudAction, FraudAnalytics, FraudCase, + FraudEvidence, FraudMerchantRecord, FraudReport, FraudRiskScore, @@ -59,14 +60,33 @@ const subscriptionSeeds: FraudSubscriptionRecord[] = [ expectedUsage: 8, observedUsage: 25, chargebacks: 0, + homeCountry: 'NG', + currentCountry: 'GH', + deviceFingerprint: 'device-ada-lagos', + trustedDeviceFingerprint: 'device-ada-lagos', riskScore: 78, action: 'flag', reason: 'Usage burst and fast creation cadence', usagePattern: 'burst', signals: [ - { kind: 'velocity', score: 28, detail: 'Created alongside two other subscriptions', observedAt: nowIso() }, - { kind: 'usage-anomaly', score: 30, detail: 'Observed usage is 3x the expected baseline', observedAt: nowIso() }, - { kind: 'chargeback', score: 20, detail: 'Recent dispute behavior is elevated', observedAt: nowIso() }, + { + kind: 'velocity', + score: 28, + detail: 'Created alongside two other subscriptions', + observedAt: nowIso(), + }, + { + kind: 'usage-anomaly', + score: 30, + detail: 'Observed usage is 3x the expected baseline', + observedAt: nowIso(), + }, + { + kind: 'chargeback', + score: 20, + detail: 'Recent dispute behavior is elevated', + observedAt: nowIso(), + }, ], isBlocked: false, isFlagged: true, @@ -83,13 +103,27 @@ const subscriptionSeeds: FraudSubscriptionRecord[] = [ expectedUsage: 10, observedUsage: 6, chargebacks: 1, + homeCountry: 'NG', + currentCountry: 'NG', + deviceFingerprint: 'device-ada-travel', + trustedDeviceFingerprint: 'device-ada-lagos', riskScore: 84, action: 'block', reason: 'Chargeback history and rapid subscription creation', usagePattern: 'erratic', signals: [ - { kind: 'velocity', score: 35, detail: 'Second subscription within the same day', observedAt: nowIso() }, - { kind: 'chargeback', score: 35, detail: 'Chargeback history predicts blocked outcome', observedAt: nowIso() }, + { + kind: 'velocity', + score: 35, + detail: 'Second subscription within the same day', + observedAt: nowIso(), + }, + { + kind: 'chargeback', + score: 35, + detail: 'Chargeback history predicts blocked outcome', + observedAt: nowIso(), + }, ], isBlocked: true, isFlagged: true, @@ -106,11 +140,22 @@ const subscriptionSeeds: FraudSubscriptionRecord[] = [ expectedUsage: 12, observedUsage: 12, chargebacks: 0, + homeCountry: 'GH', + currentCountry: 'GH', + deviceFingerprint: 'device-mina-1', + trustedDeviceFingerprint: 'device-mina-1', riskScore: 18, action: 'approve', reason: 'Usage profile is stable', usagePattern: 'normal', - signals: [{ kind: 'velocity', score: 6, detail: 'Low velocity but within threshold', observedAt: nowIso() }], + signals: [ + { + kind: 'velocity', + score: 6, + detail: 'Low velocity but within threshold', + observedAt: nowIso(), + }, + ], isBlocked: false, isFlagged: false, }, @@ -126,14 +171,33 @@ const subscriptionSeeds: FraudSubscriptionRecord[] = [ expectedUsage: 4, observedUsage: 19, chargebacks: 2, + homeCountry: 'KE', + currentCountry: 'UA', + deviceFingerprint: 'device-jon-vpn', + trustedDeviceFingerprint: 'device-jon-office', riskScore: 92, action: 'block', reason: 'Chargeback prediction and anomalous usage behavior', usagePattern: 'burst', signals: [ - { kind: 'usage-anomaly', score: 30, detail: 'Observed usage is far above baseline', observedAt: nowIso() }, - { kind: 'chargeback', score: 35, detail: 'Repeated disputes indicate high risk', observedAt: nowIso() }, - { kind: 'velocity', score: 27, detail: 'Fast subscription creation detected', observedAt: nowIso() }, + { + kind: 'usage-anomaly', + score: 30, + detail: 'Observed usage is far above baseline', + observedAt: nowIso(), + }, + { + kind: 'chargeback', + score: 35, + detail: 'Repeated disputes indicate high risk', + observedAt: nowIso(), + }, + { + kind: 'velocity', + score: 27, + detail: 'Fast subscription creation detected', + observedAt: nowIso(), + }, ], isBlocked: true, isFlagged: true, @@ -155,6 +219,7 @@ const reviewSeeds: FraudCase[] = [ createdAt: '2026-04-22T09:05:00.000Z', updatedAt: '2026-04-22T09:05:00.000Z', notes: 'Auto-flagged for analyst review', + evidence: [], }, { caseId: 'fraud_sub_2', @@ -170,6 +235,7 @@ const reviewSeeds: FraudCase[] = [ createdAt: '2026-04-22T11:15:00.000Z', updatedAt: '2026-04-22T11:15:00.000Z', notes: 'Blocked automatically', + evidence: [], }, { caseId: 'fraud_sub_4', @@ -185,25 +251,257 @@ const reviewSeeds: FraudCase[] = [ createdAt: '2026-04-24T07:05:00.000Z', updatedAt: '2026-04-24T07:05:00.000Z', notes: 'High confidence block', + evidence: [], }, ]; const averageRisk = (items: FraudSubscriptionRecord[]): number => - items.length ? Math.round(items.reduce((sum, item) => sum + item.riskScore, 0) / items.length) : 0; + items.length + ? Math.round(items.reduce((sum, item) => sum + item.riskScore, 0) / items.length) + : 0; + +const cloneSignal = (signal: FraudSignal): FraudSignal => ({ ...signal }); + +const cloneEvidence = (evidence: FraudEvidence): FraudEvidence => ({ ...evidence }); + +const cloneCase = (entry: FraudCase): FraudCase => ({ + ...entry, + evidence: entry.evidence?.map(cloneEvidence), +}); + +const dedupeSignals = (signals: FraudSignal[]): FraudSignal[] => { + const byKind = new Map(); + signals.forEach((signal) => { + const existing = byKind.get(signal.kind); + if (!existing || signal.score >= existing.score) { + byKind.set(signal.kind, signal); + } + }); + return Array.from(byKind.values()).sort((left, right) => right.score - left.score); +}; + +const countVelocitySignals = ( + item: FraudSubscriptionRecord, + subscriptions: FraudSubscriptionRecord[] +): number => { + const sameSubscriber = subscriptions.filter( + (candidate) => candidate.subscriberId === item.subscriberId + ); + const recentWindow = sameSubscriber.filter((candidate) => { + const createdAt = new Date(candidate.createdAt).getTime(); + const current = new Date(item.createdAt).getTime(); + return ( + Number.isFinite(createdAt) && + Number.isFinite(current) && + Math.abs(current - createdAt) <= 24 * 60 * 60 * 1000 + ); + }); + + return recentWindow.length > 2 ? 24 : recentWindow.length > 1 ? 14 : 0; +}; + +const determineAction = (score: number): FraudAction => { + if (score >= 80) { + return 'block'; + } + + if (score >= 50) { + return 'flag'; + } + + return 'approve'; +}; + +const buildDerivedSignals = ( + item: FraudSubscriptionRecord, + subscriptions: FraudSubscriptionRecord[] +): FraudSignal[] => { + const derived: FraudSignal[] = []; + + const velocityScore = countVelocitySignals(item, subscriptions); + if (velocityScore > 0) { + derived.push({ + kind: 'velocity', + score: velocityScore, + detail: 'subscription creation velocity is above the safe threshold', + observedAt: nowIso(), + }); + } + + if (item.expectedUsage > 0 && item.observedUsage >= item.expectedUsage * 3) { + derived.push({ + kind: 'usage-anomaly', + score: 32, + detail: 'observed usage is more than 3x the expected baseline', + observedAt: nowIso(), + }); + } else if (item.expectedUsage > 0 && item.observedUsage >= item.expectedUsage * 2) { + derived.push({ + kind: 'usage-anomaly', + score: 22, + detail: 'observed usage is more than 2x the expected baseline', + observedAt: nowIso(), + }); + } + + if ((item.chargebacks ?? 0) > 0) { + derived.push({ + kind: 'chargeback', + score: Math.min(18 + item.chargebacks * 12, 40), + detail: 'chargeback history predicts dispute exposure', + observedAt: nowIso(), + }); + } + + if (item.homeCountry && item.currentCountry && item.homeCountry !== item.currentCountry) { + derived.push({ + kind: 'geolocation-anomaly', + score: 24, + detail: `${item.currentCountry} activity differs from the normal ${item.homeCountry} profile`, + observedAt: nowIso(), + }); + } + + if ( + item.deviceFingerprint && + item.trustedDeviceFingerprint && + item.deviceFingerprint !== item.trustedDeviceFingerprint + ) { + derived.push({ + kind: 'device-mismatch', + score: 20, + detail: 'device fingerprint does not match the trusted profile', + observedAt: nowIso(), + }); + } + + if ( + item.usagePattern === 'burst' && + derived.some( + (signal) => signal.kind === 'geolocation-anomaly' || signal.kind === 'device-mismatch' + ) + ) { + derived.push({ + kind: 'pattern-shift', + score: 16, + detail: 'burst usage combined with travel or device drift suggests a pattern shift', + observedAt: nowIso(), + }); + } + + return dedupeSignals([...item.signals.map(cloneSignal), ...derived]); +}; + +const scoreSubscription = ( + item: FraudSubscriptionRecord, + subscriptions: FraudSubscriptionRecord[] = [] +): FraudRiskScore => { + const signals = buildDerivedSignals(item, subscriptions); + const velocityScore = signals.find((signal) => signal.kind === 'velocity')?.score ?? 0; + const anomalyScore = signals.find((signal) => signal.kind === 'usage-anomaly')?.score ?? 0; + const chargebackScore = signals.find((signal) => signal.kind === 'chargeback')?.score ?? 0; + const geoScore = signals.find((signal) => signal.kind === 'geolocation-anomaly')?.score ?? 0; + const deviceScore = signals.find((signal) => signal.kind === 'device-mismatch')?.score ?? 0; + const patternShiftScore = signals.find((signal) => signal.kind === 'pattern-shift')?.score ?? 0; + const falsePositivePenalty = Math.min((item.falsePositiveCount ?? 0) * 40, 60); + const totalScore = Math.max( + 0, + Math.min( + 100, + velocityScore + + anomalyScore + + chargebackScore + + geoScore + + deviceScore + + patternShiftScore - + falsePositivePenalty + ) + ); + + const evidence: FraudEvidence[] = [ + { + evidenceId: `${item.id}-payment`, + label: 'Payment profile', + value: `${item.currency} ${item.amount.toFixed(2)}`, + source: 'payment', + capturedAt: item.createdAt, + confidence: 0.88, + }, + ...(item.homeCountry && item.currentCountry && item.homeCountry !== item.currentCountry + ? [ + { + evidenceId: `${item.id}-geo`, + label: 'Location drift', + value: `${item.homeCountry} -> ${item.currentCountry}`, + source: 'location', + capturedAt: item.lastSeenAt ?? nowIso(), + confidence: 0.92, + } as FraudEvidence, + ] + : []), + ...(item.deviceFingerprint && + item.trustedDeviceFingerprint && + item.deviceFingerprint !== item.trustedDeviceFingerprint + ? [ + { + evidenceId: `${item.id}-device`, + label: 'Device mismatch', + value: `${item.trustedDeviceFingerprint} != ${item.deviceFingerprint}`, + source: 'device', + capturedAt: item.lastSeenAt ?? nowIso(), + confidence: 0.87, + } as FraudEvidence, + ] + : []), + ]; + + const reason = + geoScore >= chargebackScore && geoScore >= anomalyScore && geoScore >= velocityScore + ? 'Geolocation anomaly is the dominant signal' + : deviceScore > chargebackScore && deviceScore >= anomalyScore + ? 'Device fingerprint drift drove the score' + : chargebackScore >= anomalyScore && chargebackScore >= velocityScore + ? 'Chargeback risk dominates' + : velocityScore >= anomalyScore + ? 'Velocity risk is elevated' + : 'Usage anomaly detected'; -const computeAnalytics = (subscriptions: FraudSubscriptionRecord[], reviewQueue: FraudCase[]): FraudAnalytics => { + return { + subscriberId: item.subscriberId, + subscriptionId: item.id, + merchantId: item.merchantId, + merchantName: item.merchantName, + totalScore, + velocityScore, + anomalyScore, + chargebackScore, + action: determineAction(totalScore), + reason, + assessedAt: nowIso(), + signals, + evidence, + }; +}; + +const computeAnalytics = ( + subscriptions: FraudSubscriptionRecord[], + reviewQueue: FraudCase[] +): FraudAnalytics => { + const scores = subscriptions.map((item) => scoreSubscription(item, subscriptions)); const approved = subscriptions.filter((item) => item.action === 'approve').length; const flagged = subscriptions.filter((item) => item.action === 'flag').length; const blocked = subscriptions.filter((item) => item.action === 'block').length; - const velocityAlerts = subscriptions.filter((item) => - item.signals.some((signal) => signal.kind === 'velocity') + const velocityAlerts = scores.filter((item) => item.velocityScore > 0).length; + const anomalyAlerts = scores.filter((item) => item.anomalyScore > 0).length; + const geoAnomalyAlerts = scores.filter((item) => + item.signals.some((signal) => signal.kind === 'geolocation-anomaly') ).length; - const anomalyAlerts = subscriptions.filter((item) => - item.signals.some((signal) => signal.kind === 'usage-anomaly') - ).length; - const chargebackPredictions = subscriptions.filter((item) => - item.signals.some((signal) => signal.kind === 'chargeback') + const chargebackPredictions = scores.filter((item) => item.chargebackScore > 0).length; + const manualReviewsClosed = reviewQueue.filter( + (item) => item.status === 'reviewed' || item.status === 'dismissed' ).length; + const falsePositiveCount = reviewQueue.filter((item) => item.outcome === 'false_positive').length; + const modelConfidence = Math.max(0, 100 - Math.min(falsePositiveCount * 8, 40)); return { totalChecks: subscriptions.length, @@ -211,31 +509,19 @@ const computeAnalytics = (subscriptions: FraudSubscriptionRecord[], reviewQueue: flagged, blocked, manualReviews: reviewQueue.length, + manualReviewsClosed, avgRisk: averageRisk(subscriptions), velocityAlerts, anomalyAlerts, + geoAnomalyAlerts, chargebackPredictions, - falsePositiveEstimate: Math.max(0, Math.round(flagged * 0.18)), + falsePositiveEstimate: Math.max(0, Math.round((flagged + blocked) * 0.18)), + falsePositiveRate: + manualReviewsClosed > 0 ? Math.round((falsePositiveCount / manualReviewsClosed) * 100) : 0, + modelConfidence, }; }; -const scoreSubscription = (item: FraudSubscriptionRecord): FraudRiskScore => ({ - subscriberId: item.subscriberId, - subscriptionId: item.id, - merchantId: item.merchantId, - merchantName: item.merchantName, - totalScore: item.riskScore, - velocityScore: item.signals.find((signal) => signal.kind === 'velocity')?.score ?? 0, - anomalyScore: item.signals.find((signal) => signal.kind === 'usage-anomaly')?.score ?? 0, - chargebackScore: item.signals.find((signal) => signal.kind === 'chargeback')?.score ?? 0, - action: item.action, - reason: item.reason, - assessedAt: item.createdAt, - signals: item.signals, -}); - -const cloneCase = (entry: FraudCase): FraudCase => ({ ...entry, notes: entry.notes }); - interface FraudState { merchants: FraudMerchantRecord[]; subscriptions: FraudSubscriptionRecord[]; @@ -250,11 +536,12 @@ interface FraudState { approveSubscription: (subscriptionId: string) => void; blockSubscription: (subscriptionId: string) => void; resolveCase: (subscriptionId: string, action: FraudAction) => void; + submitFalsePositiveFeedback: (subscriptionId: string, notes?: string) => void; getFraudReport: (merchantId: string) => FraudReport; } const hydrateAssessments = (subscriptions: FraudSubscriptionRecord[]): FraudRiskScore[] => - subscriptions.map((item) => scoreSubscription(item)); + subscriptions.map((item) => scoreSubscription(item, subscriptions)); const hydrateReviewQueue = (reviews: FraudCase[]): FraudCase[] => reviews.map(cloneCase); @@ -275,6 +562,7 @@ const buildMerchantReport = ( const merchantName = merchant?.name ?? 'Unknown merchant'; const scoped = subscriptions.filter((item) => item.merchantId === merchantId); const scopedCases = reviewQueue.filter((entry) => entry.merchantId === merchantId); + const scoredScoped = scoped.map((item) => scoreSubscription(item, scoped)); return { merchantId, @@ -284,10 +572,18 @@ const buildMerchantReport = ( blockedSubscriptions: scoped.filter((item) => item.action === 'block').length, manualReviewCount: scopedCases.filter((item) => item.status !== 'reviewed').length, averageRisk: averageRisk(scoped), - velocityAlerts: scoped.filter((item) => item.signals.some((signal) => signal.kind === 'velocity')).length, - anomalyAlerts: scoped.filter((item) => item.signals.some((signal) => signal.kind === 'usage-anomaly')).length, - chargebackPredictions: scoped.filter((item) => item.signals.some((signal) => signal.kind === 'chargeback')).length, - highRiskSubscribers: new Set(scoped.filter((item) => item.riskScore >= 50).map((item) => item.subscriberId)).size, + velocityAlerts: scoredScoped.filter((item) => item.velocityScore > 0).length, + anomalyAlerts: scoredScoped.filter((item) => item.anomalyScore > 0).length, + chargebackPredictions: scoredScoped.filter((item) => item.chargebackScore > 0).length, + highRiskSubscribers: new Set( + scoredScoped.filter((item) => item.totalScore >= 50).map((item) => item.subscriberId) + ).size, + geolocationAlerts: scoredScoped.filter((item) => + item.signals.some((signal) => signal.kind === 'geolocation-anomaly') + ).length, + pendingEvidenceCount: scopedCases.filter((item) => (item.evidence?.length ?? 0) === 0).length, + falsePositiveFeedbackCount: scopedCases.filter((item) => item.outcome === 'false_positive') + .length, recentCases: scopedCases.slice(0, 5), }; }; @@ -296,7 +592,10 @@ export const useFraudStore = create()( persist( (set, get) => ({ merchants: merchantSeeds.map((merchant) => ({ ...merchant })), - subscriptions: subscriptionSeeds.map((item) => ({ ...item, signals: item.signals.map((signal) => ({ ...signal })) })), + subscriptions: subscriptionSeeds.map((item) => ({ + ...item, + signals: item.signals.map((signal) => ({ ...signal })), + })), assessments: hydrateAssessments(subscriptionSeeds), reviewQueue: hydrateReviewQueue(reviewSeeds), analytics: computeAnalytics(subscriptionSeeds, reviewSeeds), @@ -305,28 +604,50 @@ export const useFraudStore = create()( refreshFraudSignals: () => { const { subscriptions, reviewQueue, merchants } = get(); + const rescored = subscriptions.map((item) => { + const score = scoreSubscription(item, subscriptions); + return { + ...item, + riskScore: score.totalScore, + reason: score.reason, + signals: score.signals, + isFlagged: item.action !== 'approve', + isBlocked: item.action === 'block', + lastSeenAt: score.assessedAt, + falsePositiveCount: item.falsePositiveCount ?? 0, + }; + }); set({ - analytics: computeAnalytics(subscriptions, reviewQueue), - assessments: hydrateAssessments(subscriptions), - merchants: merchants.map((merchant) => ({ - ...merchant, - averageRisk: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk, - blockedSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).blockedSubscriptions, - activeSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).totalSubscriptions, - status: - buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 60 - ? 'high-risk' - : buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 35 - ? 'watch' - : 'healthy', - })), + subscriptions: rescored, + analytics: computeAnalytics(rescored, reviewQueue), + assessments: hydrateAssessments(rescored), + merchants: merchants.map((merchant) => { + const report = buildMerchantReport(merchants, rescored, reviewQueue, merchant.id); + return { + ...merchant, + averageRisk: report.averageRisk, + blockedSubscriptions: report.blockedSubscriptions, + activeSubscriptions: report.totalSubscriptions, + falsePositiveRate: + report.totalSubscriptions > 0 + ? report.falsePositiveFeedbackCount / report.totalSubscriptions + : 0, + status: + report.averageRisk >= 60 + ? 'high-risk' + : report.averageRisk >= 35 + ? 'watch' + : 'healthy', + }; + }), }); }, assessRisk: (subscriberId: string) => { - const assessments = get() - .subscriptions.filter((item) => item.subscriberId === subscriberId) - .map((item) => scoreSubscription(item)); + const subscriptions = get().subscriptions; + const assessments = subscriptions + .filter((item) => item.subscriberId === subscriberId) + .map((item) => scoreSubscription(item, subscriptions)); set((state) => ({ assessments: [ @@ -340,10 +661,11 @@ export const useFraudStore = create()( }, flagSubscription: (subscriptionId: string) => { - const current = get().subscriptions.find((item) => item.id === subscriptionId); + const subscriptions = get().subscriptions; + const current = subscriptions.find((item) => item.id === subscriptionId); if (!current) return; - const score = scoreSubscription(current); + const score = scoreSubscription(current, subscriptions); const nextCase: FraudCase = { caseId: subscriptionId, subscriptionId, @@ -358,6 +680,7 @@ export const useFraudStore = create()( createdAt: nowIso(), updatedAt: nowIso(), notes: 'Manually queued for analyst review', + evidence: score.evidence?.map(cloneEvidence), }; set((state) => ({ @@ -366,14 +689,20 @@ export const useFraudStore = create()( isFlagged: true, isBlocked: nextCase.action === 'block', }), - reviewQueue: [nextCase, ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId)], + reviewQueue: [ + nextCase, + ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId), + ], analytics: computeAnalytics( updateSubscription(state.subscriptions, subscriptionId, { action: nextCase.action, isFlagged: true, isBlocked: nextCase.action === 'block', }), - [nextCase, ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId)] + [ + nextCase, + ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId), + ] ), })); }, @@ -385,11 +714,21 @@ export const useFraudStore = create()( isFlagged: false, isBlocked: false, }); - const reviewQueue = state.reviewQueue.map((entry) => - entry.subscriptionId === subscriptionId - ? { ...entry, status: 'reviewed', action: 'approve', updatedAt: nowIso() } - : entry - ); + const reviewQueue = state.reviewQueue.map((entry) => { + if (entry.subscriptionId !== subscriptionId) { + return entry; + } + + const reviewedCase: FraudCase = { + ...entry, + status: 'reviewed', + action: 'approve', + outcome: 'true_positive', + reviewedAt: nowIso(), + updatedAt: nowIso(), + }; + return reviewedCase; + }); return { subscriptions, reviewQueue, @@ -405,11 +744,19 @@ export const useFraudStore = create()( isFlagged: true, isBlocked: true, }); - const reviewQueue = state.reviewQueue.map((entry) => - entry.subscriptionId === subscriptionId - ? { ...entry, status: 'escalated', action: 'block', updatedAt: nowIso() } - : entry - ); + const reviewQueue = state.reviewQueue.map((entry) => { + if (entry.subscriptionId !== subscriptionId) { + return entry; + } + + const escalatedCase: FraudCase = { + ...entry, + status: 'escalated', + action: 'block', + updatedAt: nowIso(), + }; + return escalatedCase; + }); return { subscriptions, reviewQueue, @@ -425,16 +772,57 @@ export const useFraudStore = create()( isFlagged: action !== 'approve', isBlocked: action === 'block', }); - const reviewQueue = state.reviewQueue.map((entry) => - entry.subscriptionId === subscriptionId - ? { - ...entry, - action, - status: action === 'approve' ? 'reviewed' : action === 'block' ? 'escalated' : 'pending', - updatedAt: nowIso(), - } - : entry - ); + const reviewQueue = state.reviewQueue.map((entry) => { + if (entry.subscriptionId !== subscriptionId) { + return entry; + } + + const resolvedCase: FraudCase = { + ...entry, + action, + status: + action === 'approve' ? 'reviewed' : action === 'block' ? 'escalated' : 'pending', + updatedAt: nowIso(), + }; + return resolvedCase; + }); + return { + subscriptions, + reviewQueue, + analytics: computeAnalytics(subscriptions, reviewQueue), + }; + }); + }, + + submitFalsePositiveFeedback: (subscriptionId: string, notes?: string) => { + set((state) => { + const subscriptions = updateSubscription(state.subscriptions, subscriptionId, { + action: 'approve', + isFlagged: false, + isBlocked: false, + falsePositiveCount: + (state.subscriptions.find((item) => item.id === subscriptionId)?.falsePositiveCount ?? + 0) + 1, + lastSeenAt: nowIso(), + }); + + const reviewQueue = state.reviewQueue.map((entry) => { + if (entry.subscriptionId !== subscriptionId) { + return entry; + } + + const dismissedCase: FraudCase = { + ...entry, + status: 'dismissed', + action: 'approve', + outcome: 'false_positive', + reviewedAt: nowIso(), + updatedAt: nowIso(), + notes: notes ?? entry.notes ?? 'Marked as false positive', + }; + return dismissedCase; + }); + return { subscriptions, reviewQueue, diff --git a/src/types/fraud.ts b/src/types/fraud.ts index 818042e..9a00f3b 100644 --- a/src/types/fraud.ts +++ b/src/types/fraud.ts @@ -1,6 +1,14 @@ export type FraudAction = 'approve' | 'flag' | 'block'; export type FraudReviewStatus = 'pending' | 'reviewed' | 'dismissed' | 'escalated'; -export type FraudSignalType = 'velocity' | 'usage-anomaly' | 'chargeback' | 'pattern-shift' | 'device-mismatch'; +export type FraudSignalType = + | 'velocity' + | 'usage-anomaly' + | 'chargeback' + | 'pattern-shift' + | 'device-mismatch' + | 'geolocation-anomaly'; +export type FraudReviewOutcome = 'true_positive' | 'false_positive' | 'needs_follow_up'; +export type FraudEvidenceSource = 'payment' | 'device' | 'location' | 'support'; export interface FraudSignal { kind: FraudSignalType; @@ -9,6 +17,15 @@ export interface FraudSignal { observedAt: string; } +export interface FraudEvidence { + evidenceId: string; + label: string; + value: string; + source: FraudEvidenceSource; + capturedAt: string; + confidence: number; +} + export interface FraudRiskScore { subscriberId: string; subscriptionId: string; @@ -22,6 +39,7 @@ export interface FraudRiskScore { reason: string; assessedAt: string; signals: FraudSignal[]; + evidence?: FraudEvidence[]; } export interface FraudCase { @@ -38,6 +56,10 @@ export interface FraudCase { createdAt: string; updatedAt: string; notes?: string; + reviewer?: string; + reviewedAt?: string; + outcome?: FraudReviewOutcome; + evidence?: FraudEvidence[]; } export interface FraudReport { @@ -52,6 +74,9 @@ export interface FraudReport { anomalyAlerts: number; chargebackPredictions: number; highRiskSubscribers: number; + geolocationAlerts: number; + pendingEvidenceCount: number; + falsePositiveFeedbackCount: number; recentCases: FraudCase[]; } @@ -67,6 +92,12 @@ export interface FraudSubscriptionRecord { expectedUsage: number; observedUsage: number; chargebacks: number; + homeCountry?: string; + currentCountry?: string; + deviceFingerprint?: string; + trustedDeviceFingerprint?: string; + lastSeenAt?: string; + falsePositiveCount?: number; riskScore: number; action: FraudAction; reason: string; @@ -84,6 +115,7 @@ export interface FraudMerchantRecord { blockedSubscriptions: number; averageRisk: number; monthlyVolume: number; + falsePositiveRate?: number; } export interface FraudAnalytics { @@ -92,9 +124,13 @@ export interface FraudAnalytics { flagged: number; blocked: number; manualReviews: number; + manualReviewsClosed: number; avgRisk: number; velocityAlerts: number; anomalyAlerts: number; + geoAnomalyAlerts: number; chargebackPredictions: number; falsePositiveEstimate: number; + falsePositiveRate: number; + modelConfidence: number; }