diff --git a/backend/services/__tests__/supportAutomation.test.ts b/backend/services/__tests__/supportAutomation.test.ts new file mode 100644 index 0000000..8eef4c8 --- /dev/null +++ b/backend/services/__tests__/supportAutomation.test.ts @@ -0,0 +1,74 @@ +import { + buildExternalPayload, + buildSupportTicket, + dedupeSupportTickets, + recordExternalSync, + recordSupportAction, +} from '../supportAutomation'; + +describe('supportAutomation', () => { + const context = { + subscriptionId: 'sub-1', + subscriptionName: 'Acme Pro', + planName: 'Acme Pro', + planTier: 'premium', + billingCycle: 'monthly', + status: 'active', + amount: 49, + currency: 'USD', + createdAt: '2026-05-01T00:00:00.000Z', + nextBillingDate: '2026-06-01T00:00:00.000Z', + failedPayments: 1, + chargeCount: 3, + history: ['Payment failed twice'], + }; + + it('dedupes open tickets for the same subscription issue', () => { + const candidate = buildSupportTicket({ + subscriptionId: 'sub-1', + issueType: 'failed_charge', + summary: 'Auto-created from failed payment', + createdAt: '2026-05-01T00:00:00.000Z', + context, + }); + const merged = dedupeSupportTickets([candidate], { + ...candidate, + id: 'candidate-2', + description: 'Duplicate failure event', + version: 1, + auditTrail: candidate.auditTrail, + actions: candidate.actions, + sla: candidate.sla, + relatedTicketIds: [], + }); + + expect(merged.id).toBe(candidate.id); + expect(merged.relatedTicketIds).toContain('candidate-2'); + }); + + it('records actions, syncs, and builds external payloads', () => { + const ticket = buildSupportTicket({ + subscriptionId: 'sub-2', + issueType: 'cancellation', + summary: 'Cancellation needs review', + createdAt: '2026-05-02T00:00:00.000Z', + context: { + ...context, + subscriptionId: 'sub-2', + subscriptionName: 'Northwind Teams', + planName: 'Team Plan', + planTier: 'enterprise', + billingCycle: 'yearly', + }, + }); + const acted = recordSupportAction(ticket, 'cancel', 'agent-1', 'Cancelled after verification'); + const synced = recordExternalSync(acted, 'zendesk', 'https://support.example.com'); + const payload = buildExternalPayload(synced, 'zendesk'); + + expect(acted.status).toBe('resolved'); + expect(synced.externalProvider).toBe('zendesk'); + expect(payload.context.subscriptionName).toBe('Northwind Teams'); + expect(payload.actions).toHaveLength(1); + expect(payload.sla.resolutionDueAt).toBeTruthy(); + }); +}); diff --git a/backend/services/index.ts b/backend/services/index.ts index 1f5c4a1..d304629 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -16,3 +16,20 @@ export { isWebhookEventAllowed, } from './webhook'; export type { RegisterWebhookInput, WebhookDeliveryResult, WebhookEventInput } from './webhook'; +export { + buildExternalPayload, + buildSupportTicket, + calculateSupportSla, + dedupeSupportTickets, + recordExternalSync, + recordSupportAction, +} from './supportAutomation'; +export type { + SupportActionRecord, + SupportActionType, + SupportIssueType, + SupportProvider, + SupportSlaSnapshot, + SupportTicketContext, + SupportTicketRecord, +} from './supportAutomation'; diff --git a/backend/services/supportAutomation.ts b/backend/services/supportAutomation.ts new file mode 100644 index 0000000..32a068d --- /dev/null +++ b/backend/services/supportAutomation.ts @@ -0,0 +1,252 @@ +import { randomUUID } from 'crypto'; + +export type SupportIssueType = 'failed_charge' | 'cancellation' | 'dispute' | 'general'; +export type SupportActionType = 'refund' | 'pause' | 'cancel' | 'escalate' | 'note'; +export type SupportProvider = 'zendesk' | 'intercom' | 'freshdesk' | 'internal'; + +export interface SupportTicketContext { + subscriptionId: string; + subscriptionName: string; + planName: string; + planTier: string; + billingCycle: string; + status: string; + amount: number; + currency: string; + createdAt: string; + nextBillingDate?: string; + failedPayments: number; + chargeCount: number; + history: string[]; +} + +export interface SupportTicketRecord { + id: string; + subscriptionId: string; + issueType: SupportIssueType; + status: 'open' | 'assigned' | 'pending_customer' | 'resolved' | 'closed'; + priority: 'low' | 'medium' | 'high' | 'urgent'; + title: string; + description: string; + dedupeKey: string; + relatedTicketIds: string[]; + context: SupportTicketContext; + actions: SupportActionRecord[]; + auditTrail: SupportAuditEntry[]; + sla: SupportSlaSnapshot; + externalTicketId?: string; + externalProvider?: SupportProvider; + version: number; +} + +export interface SupportAuditEntry { + id: string; + action: SupportActionType | 'create' | 'sync' | 'dedupe' | 'resolve'; + actorId: string; + note: string; + createdAt: string; + version: number; + metadata?: Record; +} + +export interface SupportActionRecord { + action: SupportActionType; + actorId: string; + note: string; + createdAt: string; + version: number; + conflict?: boolean; +} + +export interface SupportSlaSnapshot { + firstResponseDueAt: string; + resolutionDueAt: string; + status: 'on_track' | 'at_risk' | 'breached' | 'resolved'; + breached: boolean; +} + +const priorityForIssue: Record = { + failed_charge: 'high', + cancellation: 'medium', + dispute: 'urgent', + general: 'low', +}; + +const slaByPriority: Record = { + low: { firstResponseHours: 24, resolutionHours: 72 }, + medium: { firstResponseHours: 8, resolutionHours: 48 }, + high: { firstResponseHours: 4, resolutionHours: 24 }, + urgent: { firstResponseHours: 1, resolutionHours: 8 }, +}; + +const createId = (): string => `support_${randomUUID()}`; + +const uniqueStrings = (values: (string | undefined)[]): string[] => + Array.from(new Set(values.filter((value): value is string => Boolean(value)))); + +export const calculateSupportSla = ( + issueType: SupportIssueType, + createdAt: string, + priority: SupportTicketRecord['priority'] = priorityForIssue[issueType] +): SupportSlaSnapshot => { + const schedule = slaByPriority[priority]; + const base = new Date(createdAt).getTime(); + return { + firstResponseDueAt: new Date(base + schedule.firstResponseHours * 60 * 60 * 1000).toISOString(), + resolutionDueAt: new Date(base + schedule.resolutionHours * 60 * 60 * 1000).toISOString(), + status: 'on_track', + breached: false, + }; +}; + +export const buildSupportTicket = (input: { + subscriptionId: string; + issueType: SupportIssueType; + summary: string; + createdAt: string; + context: SupportTicketContext; + relatedTicketIds?: string[]; + dedupeKey?: string; +}): SupportTicketRecord => { + const priority = priorityForIssue[input.issueType]; + const dedupeKey = input.dedupeKey ?? `${input.subscriptionId}:${input.issueType}:${input.createdAt.slice(0, 10)}`; + const relatedTicketIds = uniqueStrings(input.relatedTicketIds ?? []); + return { + id: createId(), + subscriptionId: input.subscriptionId, + issueType: input.issueType, + status: 'open', + priority, + title: `${input.context.subscriptionName} ${input.issueType.replace('_', ' ')}`, + description: input.summary, + dedupeKey, + relatedTicketIds, + context: input.context, + actions: [], + auditTrail: [ + { + id: createId(), + action: 'create', + actorId: 'system', + note: input.summary, + createdAt: input.createdAt, + version: 1, + }, + ], + sla: calculateSupportSla(input.issueType, input.createdAt, priority), + version: 1, + }; +}; + +export const dedupeSupportTickets = ( + existingTickets: SupportTicketRecord[], + candidate: SupportTicketRecord +): SupportTicketRecord => { + const match = existingTickets.find( + (ticket) => + ticket.subscriptionId === candidate.subscriptionId && + ticket.issueType === candidate.issueType && + ticket.status !== 'closed' + ); + + if (!match) return candidate; + + return { + ...match, + relatedTicketIds: uniqueStrings([...match.relatedTicketIds, candidate.id, ...candidate.relatedTicketIds]), + context: { + ...match.context, + history: uniqueStrings([...match.context.history, ...candidate.context.history, candidate.description]), + }, + version: match.version + 1, + auditTrail: [ + ...match.auditTrail, + { + id: createId(), + action: 'dedupe', + actorId: 'system', + note: `Merged ${candidate.id} into existing ticket`, + createdAt: new Date().toISOString(), + version: match.version + 1, + metadata: { mergedTicketId: candidate.id }, + }, + ], + }; +}; + +export const recordSupportAction = ( + ticket: SupportTicketRecord, + action: SupportActionType, + actorId: string, + note: string, + expectedVersion?: number +): SupportTicketRecord => { + const conflict = expectedVersion !== undefined && expectedVersion !== ticket.version; + const nextVersion = ticket.version + 1; + const nextStatus = action === 'refund' || action === 'pause' || action === 'cancel' ? 'resolved' : 'assigned'; + + return { + ...ticket, + status: conflict ? ticket.status : nextStatus, + version: nextVersion, + actions: [ + ...ticket.actions, + { + action, + actorId, + note, + createdAt: new Date().toISOString(), + version: nextVersion, + conflict, + }, + ], + auditTrail: [ + ...ticket.auditTrail, + { + id: createId(), + action: conflict ? 'note' : action, + actorId, + note: conflict ? `Conflict: ${note}` : note, + createdAt: new Date().toISOString(), + version: nextVersion, + metadata: conflict ? { expectedVersion: expectedVersion ?? -1, actualVersion: ticket.version } : { status: nextStatus }, + }, + ], + }; +}; + +export const recordExternalSync = ( + ticket: SupportTicketRecord, + provider: SupportProvider, + baseUrl?: string +): SupportTicketRecord => ({ + ...ticket, + externalProvider: provider, + externalTicketId: ticket.externalTicketId ?? `${provider}-${ticket.id}`, + auditTrail: [ + ...ticket.auditTrail, + { + id: createId(), + action: 'sync', + actorId: 'system', + note: `Synced to ${provider}`, + createdAt: new Date().toISOString(), + version: ticket.version, + metadata: { provider, baseUrl: baseUrl ?? '' }, + }, + ], +}); + +export const buildExternalPayload = (ticket: SupportTicketRecord, provider: SupportProvider) => ({ + provider, + ticketId: ticket.id, + subscriptionId: ticket.subscriptionId, + title: ticket.title, + description: ticket.description, + status: ticket.status, + priority: ticket.priority, + context: ticket.context, + sla: ticket.sla, + actions: ticket.actions, + relatedTicketIds: ticket.relatedTicketIds, +}); 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/CancellationFlowScreen.tsx b/src/screens/CancellationFlowScreen.tsx index 1e83523..0c18e66 100644 --- a/src/screens/CancellationFlowScreen.tsx +++ b/src/screens/CancellationFlowScreen.tsx @@ -6,11 +6,13 @@ import { Card } from '../components/common/Card'; import { colors, spacing, typography } from '../utils/constants'; import { RootStackParamList } from '../navigation/types'; import { useCancellationStore } from '../store/cancellationStore'; +import { useSubscriptionStore } from '../store'; type Props = NativeStackScreenProps; const CancellationFlowScreen: React.FC = ({ route, navigation }) => { const { currentStep, setReason, setStep, acceptOffer, reset } = useCancellationStore(); + const { deleteSubscription } = useSubscriptionStore(); const { subscriptionId } = route.params; @@ -64,7 +66,10 @@ const CancellationFlowScreen: React.FC = ({ route, navigation }) => {