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/subscription/src/gas_optimization.rs b/contracts/subscription/src/gas_optimization.rs index ff1a24d..b05915d 100644 --- a/contracts/subscription/src/gas_optimization.rs +++ b/contracts/subscription/src/gas_optimization.rs @@ -1,7 +1,6 @@ /// Gas Optimization and Targeting Module /// Provides optimization recommendations and tracks gas targets - -use soroban_sdk::{String, Vec, Env}; +use soroban_sdk::{Env, String, Vec}; /// Optimization level #[derive(Clone, Copy)] @@ -112,20 +111,56 @@ impl GasOptimizationTargets { pub fn all_targets(env: &Env) -> Vec<(String, u64)> { soroban_sdk::vec![ env, - (String::from_str(env, "initialize"), Self::initialize_target()), - (String::from_str(env, "create_plan"), Self::create_plan_target()), + ( + String::from_str(env, "initialize"), + Self::initialize_target() + ), + ( + String::from_str(env, "create_plan"), + Self::create_plan_target() + ), (String::from_str(env, "subscribe"), Self::subscribe_target()), - (String::from_str(env, "charge_subscription"), Self::charge_subscription_target()), - (String::from_str(env, "cancel_subscription"), Self::cancel_subscription_target()), - (String::from_str(env, "pause_subscription"), Self::pause_subscription_target()), - (String::from_str(env, "resume_subscription"), Self::resume_subscription_target()), - (String::from_str(env, "request_refund"), Self::request_refund_target()), - (String::from_str(env, "approve_refund"), Self::approve_refund_target()), - (String::from_str(env, "request_transfer"), Self::request_transfer_target()), - (String::from_str(env, "accept_transfer"), Self::accept_transfer_target()), + ( + String::from_str(env, "charge_subscription"), + Self::charge_subscription_target() + ), + ( + String::from_str(env, "cancel_subscription"), + Self::cancel_subscription_target() + ), + ( + String::from_str(env, "pause_subscription"), + Self::pause_subscription_target() + ), + ( + String::from_str(env, "resume_subscription"), + Self::resume_subscription_target() + ), + ( + String::from_str(env, "request_refund"), + Self::request_refund_target() + ), + ( + String::from_str(env, "approve_refund"), + Self::approve_refund_target() + ), + ( + String::from_str(env, "request_transfer"), + Self::request_transfer_target() + ), + ( + String::from_str(env, "accept_transfer"), + Self::accept_transfer_target() + ), (String::from_str(env, "get_plan"), Self::get_plan_target()), - (String::from_str(env, "get_subscription"), Self::get_subscription_target()), - (String::from_str(env, "get_user_subscriptions"), Self::get_user_subscriptions_target()), + ( + String::from_str(env, "get_subscription"), + Self::get_subscription_target() + ), + ( + String::from_str(env, "get_user_subscriptions"), + Self::get_user_subscriptions_target() + ), ] } } @@ -135,7 +170,11 @@ pub struct GasOptimizations; impl GasOptimizations { /// Get optimization recommendations for a specific function - pub fn get_recommendations_for_function(env: &Env, function_name: &str, current_gas: u64) -> Vec { + pub fn get_recommendations_for_function( + env: &Env, + function_name: &str, + current_gas: u64, + ) -> Vec { let mut recommendations = Vec::new(env); match function_name { @@ -192,7 +231,10 @@ impl GasOptimizations { } } _ => { - recommendations.push_back(String::from_str(env, "Monitor function for optimization opportunities")); + recommendations.push_back(String::from_str( + env, + "Monitor function for optimization opportunities", + )); } } @@ -270,7 +312,7 @@ impl GasOptimizations { } } - pub fn get_optimization_priorities( +pub fn get_optimization_priorities( env: &Env, gas_metrics: Vec<(String, u64)>, ) -> Vec<(String, u64, String)> { @@ -279,7 +321,7 @@ impl GasOptimizations { /// Best practices for gas efficiency pub mod best_practices { - use soroban_sdk::{String, Vec, Env}; + use soroban_sdk::{Env, String, Vec}; pub fn get_storage_best_practices(env: &Env) -> Vec { let mut practices = Vec::new(env); @@ -353,10 +395,7 @@ pub mod best_practices { pub fn get_validation_best_practices(env: &Env) -> Vec { let mut practices = Vec::new(env); - practices.push_back(String::from_str( - env, - "Validate inputs early to fail fast", - )); + practices.push_back(String::from_str(env, "Validate inputs early to fail fast")); practices.push_back(String::from_str( env, "Use assertions for critical validations", diff --git a/contracts/subscription/src/gas_profiler.rs b/contracts/subscription/src/gas_profiler.rs index efb554b..d0e59d7 100644 --- a/contracts/subscription/src/gas_profiler.rs +++ b/contracts/subscription/src/gas_profiler.rs @@ -1,7 +1,6 @@ /// Gas Profiling Module for SubTrackr Subscription Contract /// Tracks gas consumption for each contract function and provides optimization insights - -use soroban_sdk::{Address, Env, String, Symbol,Vec}; +use soroban_sdk::{Address, Env, String, Symbol, Vec}; /// Gas profile entry for a function call #[derive(Clone)] @@ -27,10 +26,10 @@ pub struct GasMetrics { /// Function complexity categories pub enum FunctionCategory { - Read, // Simple read operations, < 50k gas - Write, // Storage write operations, 50k-150k gas - Transfer, // Token transfers, 100k-200k gas - Complex, // Multi-step operations, > 200k gas + Read, // Simple read operations, < 50k gas + Write, // Storage write operations, 50k-150k gas + Transfer, // Token transfers, 100k-200k gas + Complex, // Multi-step operations, > 200k gas } impl FunctionCategory { @@ -63,14 +62,14 @@ impl FunctionCategory { /// Storage keys for gas profiling data pub enum GasStorageKey { - Profile(String), // Function name -> GasProfile - Metrics(String), // Function name -> GasMetrics - DailyGasUsage(u64), // day timestamp -> total gas - WeeklyGasUsage(u64), // week timestamp -> total gas - MonthlyGasUsage(u64), // month timestamp -> total gas - TotalGasUsed, // u64: cumulative gas used - CallCount, // u64: total number of calls - GasAlertTriggered(String, u64), // alert type -> count + Profile(String), // Function name -> GasProfile + Metrics(String), // Function name -> GasMetrics + DailyGasUsage(u64), // day timestamp -> total gas + WeeklyGasUsage(u64), // week timestamp -> total gas + MonthlyGasUsage(u64), // month timestamp -> total gas + TotalGasUsed, // u64: cumulative gas used + CallCount, // u64: total number of calls + GasAlertTriggered(String, u64), // alert type -> count } /// Gas profiler implementation @@ -86,17 +85,17 @@ impl GasProfiler { category: FunctionCategory, ) { let fname = function_name.clone(); - + // Record function profile Self::update_profile(env, storage, &fname, gas_used); - + // Update daily/weekly/monthly tracking let now = env.ledger().timestamp(); Self::update_time_series(env, storage, now, gas_used); - + // Check if gas usage exceeds thresholds Self::check_gas_thresholds(env, storage, &fname, gas_used, category); - + // Update total counters Self::increment_counters(env, storage, gas_used); } @@ -104,7 +103,7 @@ impl GasProfiler { /// Update function profile statistics fn update_profile(env: &Env, storage: &Address, function_name: &String, gas_used: u64) { let key = GasStorageKey::Profile(function_name.clone()); - + let mut profile: GasProfile = match Self::get_profile(env, storage, function_name) { Some(p) => p, None => GasProfile { @@ -120,8 +119,16 @@ impl GasProfiler { profile.call_count += 1; profile.total_gas += gas_used; - profile.min_gas = if gas_used < profile.min_gas { gas_used } else { profile.min_gas }; - profile.max_gas = if gas_used > profile.max_gas { gas_used } else { profile.max_gas }; + profile.min_gas = if gas_used < profile.min_gas { + gas_used + } else { + profile.min_gas + }; + profile.max_gas = if gas_used > profile.max_gas { + gas_used + } else { + profile.max_gas + }; profile.avg_gas = profile.total_gas / profile.call_count; profile.last_updated = env.ledger().timestamp(); @@ -129,11 +136,7 @@ impl GasProfiler { } /// Get gas profile for a function - pub fn get_profile( - env: &Env, - storage: &Address, - function_name: &String, - ) -> Option { + pub fn get_profile(env: &Env, storage: &Address, function_name: &String) -> Option { // This would retrieve from storage // Simplified for demonstration None @@ -168,13 +171,19 @@ impl GasProfiler { if gas_used > error_threshold { // Trigger error alert env.events().publish( - (String::from_str(env, "gas_error_alert"), function_name.clone()), + ( + String::from_str(env, "gas_error_alert"), + function_name.clone(), + ), (gas_used, error_threshold, category.to_string()), ); } else if gas_used > warning_threshold { // Trigger warning alert env.events().publish( - (String::from_str(env, "gas_warning_alert"), function_name.clone()), + ( + String::from_str(env, "gas_warning_alert"), + function_name.clone(), + ), (gas_used, warning_threshold, category.to_string()), ); } @@ -233,10 +242,7 @@ impl GasProfiler { } /// Get optimization recommendations - pub fn get_optimization_recommendations( - env: &Env, - storage: &Address, - ) -> Vec { + pub fn get_optimization_recommendations(env: &Env, storage: &Address) -> Vec { // Returns array of optimization suggestions based on profiling data soroban_sdk::vec![env] } @@ -289,8 +295,8 @@ impl Drop for GasTrackGuard { fn drop(&mut self) { // Record gas usage on scope exit let start = self.env.ledger().timestamp(); - let end = self.env.ledger().sequence(); - let gas_delta = end - start as u32; // Simplified for demonstration + let end = self.env.ledger().sequence(); + let gas_delta = end - start as u32; // Simplified for demonstration GasProfiler::record_call( &self.env, &self.storage, diff --git a/contracts/subscription/src/gas_storage.rs b/contracts/subscription/src/gas_storage.rs index 85c4e2e..60a76dd 100644 --- a/contracts/subscription/src/gas_storage.rs +++ b/contracts/subscription/src/gas_storage.rs @@ -1,8 +1,7 @@ +use crate::gas_profiler::GasProfile; /// Gas Storage Module /// Manages storage and retrieval of gas profiling metrics - -use soroban_sdk::{Address, Env, String as SorobanString, TryFromVal, Val, IntoVal, Vec}; -use crate::gas_profiler::{GasProfile}; +use soroban_sdk::{Address, Env, IntoVal, String as SorobanString, TryFromVal, Val, Vec}; /// Storage keys for gas metrics #[derive(Clone)] @@ -30,11 +29,7 @@ pub struct GasMetricsStorage; impl GasMetricsStorage { /// Store a gas profile for a function - pub fn store_profile( - env: &Env, - storage: &Address, - profile: &GasProfile, - ) { + pub fn store_profile(env: &Env, storage: &Address, profile: &GasProfile) { let key = format_gas_profile_key(env, &profile.function_name); // Serialize and store profile // This would use actual storage @@ -51,12 +46,7 @@ impl GasMetricsStorage { } /// Update daily gas aggregates - pub fn update_daily_aggregate( - env: &Env, - storage: &Address, - day_timestamp: u64, - gas_used: u64, - ) { + pub fn update_daily_aggregate(env: &Env, storage: &Address, day_timestamp: u64, gas_used: u64) { // Increment daily aggregate for the given day } @@ -81,31 +71,19 @@ impl GasMetricsStorage { } /// Get daily gas usage - pub fn get_daily_usage( - env: &Env, - storage: &Address, - day_timestamp: u64, - ) -> u64 { + pub fn get_daily_usage(env: &Env, storage: &Address, day_timestamp: u64) -> u64 { // Retrieve daily aggregate 0 } /// Get weekly gas usage - pub fn get_weekly_usage( - env: &Env, - storage: &Address, - week_timestamp: u64, - ) -> u64 { + pub fn get_weekly_usage(env: &Env, storage: &Address, week_timestamp: u64) -> u64 { // Retrieve weekly aggregate 0 } /// Get monthly gas usage - pub fn get_monthly_usage( - env: &Env, - storage: &Address, - month_timestamp: u64, - ) -> u64 { + pub fn get_monthly_usage(env: &Env, storage: &Address, month_timestamp: u64) -> u64 { // Retrieve monthly aggregate 0 } @@ -123,11 +101,7 @@ impl GasMetricsStorage { } /// Increment total gas used - pub fn increment_total_gas( - env: &Env, - storage: &Address, - gas_amount: u64, - ) { + pub fn increment_total_gas(env: &Env, storage: &Address, gas_amount: u64) { // Increment total gas } @@ -137,43 +111,26 @@ impl GasMetricsStorage { } /// Record gas alert - pub fn record_alert( - env: &Env, - storage: &Address, - alert_type: &str, - ) { + pub fn record_alert(env: &Env, storage: &Address, alert_type: &str) { let alert_key = SorobanString::from_str(env, alert_type); // Increment alert count } /// Get gas alert count by type - pub fn get_alert_count( - env: &Env, - storage: &Address, - alert_type: &str, - ) -> u64 { + pub fn get_alert_count(env: &Env, storage: &Address, alert_type: &str) -> u64 { let alert_key = SorobanString::from_str(env, alert_type); // Retrieve alert count 0 } /// Update last recorded gas usage for a function - pub fn update_last_usage( - env: &Env, - storage: &Address, - function_name: &str, - gas_used: u64, - ) { + pub fn update_last_usage(env: &Env, storage: &Address, function_name: &str, gas_used: u64) { let fname = SorobanString::from_str(env, function_name); // Update last usage } /// Get last recorded gas usage - pub fn get_last_usage( - env: &Env, - storage: &Address, - function_name: &str, - ) -> Option { + pub fn get_last_usage(env: &Env, storage: &Address, function_name: &str) -> Option { let fname = SorobanString::from_str(env, function_name); // Retrieve last usage None diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 75ccad9..0e88178 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -1,10 +1,12 @@ #![no_std] +mod gas_optimization; mod gas_profiler; mod gas_storage; -mod gas_optimization; use soroban_sdk::{token, Address, Env, IntoVal, String, TryFromVal, Val, Vec}; use subtrackr_types::{ - Interval, Invoice, Plan, StorageKey, Subscription, SubscriptionStatus, TimeRange, + CreditApplicationReceipt, CreditLedgerEntry, CreditLedgerEntryKind, CreditLot, + CreditPaymentMethod, CreditPolicy, Interval, Invoice, InvoiceStatus, Plan, StorageKey, + Subscription, SubscriptionStatus, TimeRange, }; /// Billing interval in seconds. @@ -185,6 +187,111 @@ fn invoice_contract(env: &Env, storage: &Address) -> Option
{ storage_instance_get(env, storage, StorageKey::InvoiceContract) } +fn default_credit_policy(_env: &Env) -> CreditPolicy { + CreditPolicy { + expiration_days: 365, + transferable: true, + auto_apply: true, + allow_partial: true, + } +} + +fn credit_policy(env: &Env, storage: &Address, account: &Address) -> CreditPolicy { + storage_persistent_get(env, storage, StorageKey::CreditPolicy(account.clone())) + .unwrap_or(default_credit_policy(env)) +} + +fn persist_credit_policy(env: &Env, storage: &Address, account: &Address, policy: CreditPolicy) { + storage_persistent_set( + env, + storage, + StorageKey::CreditPolicy(account.clone()), + policy, + ); +} + +fn credit_balance(env: &Env, storage: &Address, account: &Address) -> i128 { + storage_persistent_get(env, storage, StorageKey::CreditBalance(account.clone())).unwrap_or(0) +} + +fn set_credit_balance(env: &Env, storage: &Address, account: &Address, balance: i128) { + storage_persistent_set( + env, + storage, + StorageKey::CreditBalance(account.clone()), + balance, + ); +} + +fn credit_lots(env: &Env, storage: &Address, account: &Address) -> Vec { + storage_persistent_get(env, storage, StorageKey::CreditLots(account.clone())) + .unwrap_or(Vec::new(env)) +} + +fn set_credit_lots(env: &Env, storage: &Address, account: &Address, lots: Vec) { + storage_persistent_set(env, storage, StorageKey::CreditLots(account.clone()), lots); +} + +fn credit_ledger(env: &Env, storage: &Address, account: &Address) -> Vec { + storage_persistent_get(env, storage, StorageKey::CreditLedger(account.clone())) + .unwrap_or(Vec::new(env)) +} + +fn set_credit_ledger( + env: &Env, + storage: &Address, + account: &Address, + ledger: Vec, +) { + storage_persistent_set( + env, + storage, + StorageKey::CreditLedger(account.clone()), + ledger, + ); +} + +fn next_credit_entry_id(ledger: &Vec) -> u64 { + ledger.len() as u64 + 1 +} + +fn next_credit_lot_id(lots: &Vec) -> u64 { + lots.len() as u64 + 1 +} + +fn build_credit_ledger_entry( + env: &Env, + id: u64, + account: &Address, + kind: CreditLedgerEntryKind, + amount: i128, + balance_after: i128, + subscription_id: u64, + invoice_id: String, + related_account: Address, + payment_method: CreditPaymentMethod, + reference: String, + note: String, + expires_at: u64, +) -> CreditLedgerEntry { + CreditLedgerEntry { + id, + account: account.clone(), + kind, + amount, + balance_after, + running_total: balance_after, + created_at: env.ledger().timestamp(), + expires_at, + subscription_id, + invoice_id, + related_account, + payment_method, + reference, + note, + } +} + // ───────────────────────────────────────────────────────────────────────────── // Implementation Contract // ───────────────────────────────────────────────────────────────────────────── @@ -707,6 +814,383 @@ impl SubTrackrSubscription { } } + // ── Credit Balance API ── + + pub fn set_credit_policy( + env: Env, + proxy: Address, + storage: Address, + account: Address, + policy: CreditPolicy, + ) { + proxy.require_auth(); + account.require_auth(); + persist_credit_policy(&env, &storage, &account, policy); + } + + pub fn purchase_credits( + env: Env, + proxy: Address, + storage: Address, + account: Address, + amount: i128, + payment_method: CreditPaymentMethod, + expires_in_days: u32, + reference: String, + note: String, + ) -> i128 { + proxy.require_auth(); + account.require_auth(); + assert!(amount > 0, "Credit amount must be positive"); + + let policy = credit_policy(&env, &storage, &account); + let expiry_days = if expires_in_days == 0 { + policy.expiration_days + } else { + expires_in_days + }; + let now = env.ledger().timestamp(); + let expires_at = if expiry_days == 0 { + 0 + } else { + now + (expiry_days as u64 * 86_400) + }; + + let mut lots = credit_lots(&env, &storage, &account); + let lot = CreditLot { + id: next_credit_lot_id(&lots), + account: account.clone(), + amount_remaining: amount, + original_amount: amount, + created_at: now, + expires_at, + payment_method: payment_method.clone(), + reference: reference.clone(), + note: note.clone(), + }; + lots.push_back(lot); + set_credit_lots(&env, &storage, &account, lots); + + let balance = credit_balance(&env, &storage, &account) + amount; + set_credit_balance(&env, &storage, &account, balance); + + let mut ledger = credit_ledger(&env, &storage, &account); + let entry = build_credit_ledger_entry( + &env, + next_credit_entry_id(&ledger), + &account, + CreditLedgerEntryKind::Purchase, + amount, + balance, + 0, + String::from_str(&env, ""), + account.clone(), + payment_method, + reference, + note, + expires_at, + ); + ledger.push_back(entry); + set_credit_ledger(&env, &storage, &account, ledger); + + env.events().publish( + (String::from_str(&env, "credit_purchased"), account.clone()), + (amount, balance, expires_at), + ); + + balance + } + + pub fn transfer_credits( + env: Env, + proxy: Address, + storage: Address, + from: Address, + to: Address, + amount: i128, + reference: String, + note: String, + ) -> i128 { + proxy.require_auth(); + from.require_auth(); + assert!(amount > 0, "Transfer amount must be positive"); + assert!(from != to, "Cannot transfer to self"); + + let policy = credit_policy(&env, &storage, &from); + assert!(policy.transferable, "Credits are not transferable"); + + let balance = credit_balance(&env, &storage, &from); + assert!(balance >= amount, "Insufficient credit balance"); + + let mut source_lots = credit_lots(&env, &storage, &from); + let mut recipient_lots = credit_lots(&env, &storage, &to); + let mut remaining = amount; + let mut moved = 0i128; + let now = env.ledger().timestamp(); + + let mut next_source_lots = Vec::new(&env); + for lot in source_lots.iter() { + let mut next_lot = lot.clone(); + if remaining > 0 && next_lot.amount_remaining > 0 { + let consume = if next_lot.amount_remaining < remaining { + next_lot.amount_remaining + } else { + remaining + }; + next_lot.amount_remaining -= consume; + remaining -= consume; + moved += consume; + + let recipient_lot = CreditLot { + id: next_credit_lot_id(&recipient_lots), + account: to.clone(), + amount_remaining: consume, + original_amount: consume, + created_at: now, + expires_at: next_lot.expires_at, + payment_method: next_lot.payment_method.clone(), + reference: reference.clone(), + note: note.clone(), + }; + recipient_lots.push_back(recipient_lot); + } + next_source_lots.push_back(next_lot); + } + + set_credit_lots(&env, &storage, &from, next_source_lots); + set_credit_lots(&env, &storage, &to, recipient_lots); + + let source_balance = balance - moved; + let recipient_balance = credit_balance(&env, &storage, &to) + moved; + set_credit_balance(&env, &storage, &from, source_balance); + set_credit_balance(&env, &storage, &to, recipient_balance); + + let mut source_ledger = credit_ledger(&env, &storage, &from); + let source_entry = build_credit_ledger_entry( + &env, + next_credit_entry_id(&source_ledger), + &from, + CreditLedgerEntryKind::TransferOut, + -moved, + source_balance, + 0, + String::from_str(&env, ""), + to.clone(), + CreditPaymentMethod::Manual, + reference.clone(), + note.clone(), + 0, + ); + source_ledger.push_back(source_entry); + set_credit_ledger(&env, &storage, &from, source_ledger); + + let mut recipient_ledger = credit_ledger(&env, &storage, &to); + let recipient_entry = build_credit_ledger_entry( + &env, + next_credit_entry_id(&recipient_ledger), + &to, + CreditLedgerEntryKind::TransferIn, + moved, + recipient_balance, + 0, + String::from_str(&env, ""), + from.clone(), + CreditPaymentMethod::Manual, + reference, + note, + 0, + ); + recipient_ledger.push_back(recipient_entry); + set_credit_ledger(&env, &storage, &to, recipient_ledger); + + env.events().publish( + (String::from_str(&env, "credit_transferred"), from.clone()), + (to.clone(), moved, source_balance, recipient_balance), + ); + + recipient_balance + } + + pub fn apply_credit_to_invoice( + env: Env, + proxy: Address, + storage: Address, + account: Address, + subscription_id: u64, + invoice_id: String, + invoice_total: i128, + ) -> CreditApplicationReceipt { + proxy.require_auth(); + account.require_auth(); + assert!(invoice_total > 0, "Invoice total must be positive"); + + let policy = credit_policy(&env, &storage, &account); + let balance = credit_balance(&env, &storage, &account); + let mut applied = if balance < invoice_total { + balance + } else { + invoice_total + }; + if !policy.allow_partial && applied < invoice_total { + applied = 0; + } + + let mut lots = credit_lots(&env, &storage, &account); + let mut remaining = applied; + let mut updated_lots = Vec::new(&env); + for lot in lots.iter() { + let mut next_lot = lot.clone(); + if remaining > 0 && next_lot.amount_remaining > 0 { + let consume = if next_lot.amount_remaining < remaining { + next_lot.amount_remaining + } else { + remaining + }; + next_lot.amount_remaining -= consume; + remaining -= consume; + } + updated_lots.push_back(next_lot); + } + + if applied > 0 { + set_credit_lots(&env, &storage, &account, updated_lots); + let new_balance = balance - applied; + set_credit_balance(&env, &storage, &account, new_balance); + + let mut ledger = credit_ledger(&env, &storage, &account); + let entry = build_credit_ledger_entry( + &env, + next_credit_entry_id(&ledger), + &account, + CreditLedgerEntryKind::Application, + -applied, + new_balance, + subscription_id, + invoice_id.clone(), + account.clone(), + CreditPaymentMethod::Manual, + String::from_str(&env, "invoice-application"), + String::from_str(&env, "Auto-applied to upcoming invoice"), + 0, + ); + ledger.push_back(entry); + set_credit_ledger(&env, &storage, &account, ledger); + + env.events().publish( + (String::from_str(&env, "credit_applied"), account.clone()), + (subscription_id, invoice_id.clone(), applied, new_balance), + ); + } + + let remaining_due = invoice_total - applied; + let status = if applied == 0 { + InvoiceStatus::Sent + } else if remaining_due > 0 { + InvoiceStatus::Partial + } else { + InvoiceStatus::Paid + }; + + CreditApplicationReceipt { + invoice_id, + subscription_id, + applied_amount: applied, + remaining_due, + status, + } + } + + pub fn expire_credits(env: Env, proxy: Address, storage: Address, account: Address) -> i128 { + proxy.require_auth(); + account.require_auth(); + let now = env.ledger().timestamp(); + let policy = credit_policy(&env, &storage, &account); + let mut lots = credit_lots(&env, &storage, &account); + let mut expired = 0i128; + let mut updated = Vec::new(&env); + for lot in lots.iter() { + let mut next_lot = lot.clone(); + let is_due = next_lot.expires_at > 0 && next_lot.expires_at <= now; + if is_due && next_lot.amount_remaining > 0 { + expired += next_lot.amount_remaining; + next_lot.amount_remaining = 0; + } + updated.push_back(next_lot); + } + + if expired > 0 { + set_credit_lots(&env, &storage, &account, updated); + let balance = credit_balance(&env, &storage, &account) - expired; + set_credit_balance(&env, &storage, &account, balance); + + let mut ledger = credit_ledger(&env, &storage, &account); + let entry = build_credit_ledger_entry( + &env, + next_credit_entry_id(&ledger), + &account, + CreditLedgerEntryKind::Expiration, + -expired, + balance, + 0, + String::from_str(&env, ""), + account.clone(), + CreditPaymentMethod::Manual, + String::from_str(&env, "expiration"), + String::from_str(&env, "Credits expired by policy"), + 0, + ); + ledger.push_back(entry); + set_credit_ledger(&env, &storage, &account, ledger); + + env.events().publish( + (String::from_str(&env, "credit_expired"), account.clone()), + (expired, balance, policy.expiration_days), + ); + } + + expired + } + + pub fn get_credit_balance( + env: Env, + proxy: Address, + storage: Address, + account: Address, + ) -> i128 { + proxy.require_auth(); + credit_balance(&env, &storage, &account) + } + + pub fn get_credit_policy( + env: Env, + proxy: Address, + storage: Address, + account: Address, + ) -> CreditPolicy { + proxy.require_auth(); + credit_policy(&env, &storage, &account) + } + + pub fn get_credit_lots( + env: Env, + proxy: Address, + storage: Address, + account: Address, + ) -> Vec { + proxy.require_auth(); + credit_lots(&env, &storage, &account) + } + + pub fn get_credit_ledger( + env: Env, + proxy: Address, + storage: Address, + account: Address, + ) -> Vec { + proxy.require_auth(); + credit_ledger(&env, &storage, &account) + } + pub fn request_refund( env: Env, proxy: Address, diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 86daa83..a641a11 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -128,6 +128,79 @@ pub struct Subscription { pub refund_requested_amount: i128, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum CreditPaymentMethod { + Card, + BankTransfer, + Wallet, + Manual, + Crypto, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum CreditLedgerEntryKind { + Purchase, + Application, + Expiration, + TransferIn, + TransferOut, + Adjustment, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CreditPolicy { + pub expiration_days: u32, + pub transferable: bool, + pub auto_apply: bool, + pub allow_partial: bool, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CreditLot { + pub id: u64, + pub account: Address, + pub amount_remaining: i128, + pub original_amount: i128, + pub created_at: Timestamp, + pub expires_at: Timestamp, + pub payment_method: CreditPaymentMethod, + pub reference: String, + pub note: String, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CreditLedgerEntry { + pub id: u64, + pub account: Address, + pub kind: CreditLedgerEntryKind, + pub amount: i128, + pub balance_after: i128, + pub running_total: i128, + pub created_at: Timestamp, + pub expires_at: Timestamp, + pub subscription_id: u64, + pub invoice_id: String, + pub related_account: Address, + pub payment_method: CreditPaymentMethod, + pub reference: String, + pub note: String, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CreditApplicationReceipt { + pub invoice_id: String, + pub subscription_id: u64, + pub applied_amount: i128, + pub remaining_due: i128, + pub status: InvoiceStatus, +} + pub type Timestamp = u64; #[contracttype] @@ -229,6 +302,7 @@ pub enum RiskSignalKind { Chargeback, PatternShift, DeviceMismatch, + GeolocationAnomaly, } #[contracttype] @@ -240,6 +314,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 +334,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 +357,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 +372,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, } @@ -360,4 +453,10 @@ pub enum StorageKey { PlanQuotas(u64), /// Usage record for a subscription and metric (sub_id, metric -> UsageRecord) SubscriptionUsage(u64, QuotaMetric), + + // ── Credit balance state ── + CreditBalance(Address), + CreditPolicy(Address), + CreditLots(Address), + CreditLedger(Address), } 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 }) => {