diff --git a/docs/API.md b/docs/API.md index 04d05c2a..1141eacf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -379,6 +379,29 @@ Returns all subscription IDs for a given subscriber. | ------------ | --------- | ---------- | | `subscriber` | `Address` | `Vec` | +#### `generateMerchantReport(dashboardData, format)` +Generates exportable standardized merchant reports in `json` or `csv` format. + +--- + +## Automated Compliance API + +The `ComplianceService` handles continuous regulatory monitoring, automated checks, alerts management, audit trail logging, and reporting exports. + +### Methods + +#### `runAutomatedChecks(subscriptions)` +Executes compliance rule evaluation across all subscriptions and generates warning/failure alerts. + +#### `getAlerts()` & `acknowledgeAlert(alertId, performer)` +Retrieves active compliance alerts and acknowledges specific regulatory issues. + +#### `getAuditTrail()` & `logAuditEntry(action, performer, targetId, details)` +Manages immutable audit trail logs for compliance operations. + +#### `generateComplianceReport(format)` +Exports full compliance status and audit logs in `json` or `csv`. + #### `get_merchant_plans` Returns all plan IDs for a given merchant. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4489a4b3..3e5f82fc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -499,6 +499,10 @@ components: tags: - name: Initialization description: One-time contract setup + - name: Merchant Analytics + description: Merchant revenue, subscriber analytics, and reporting + - name: Automated Compliance + description: Automated compliance checks, alerts, regulatory monitoring, and audit trail - name: Plan Management description: Create and manage subscription plans - name: Subscription Management diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index b09eb45a..6ef774e8 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -96,6 +96,7 @@ const PaymentMethodsScreen = lazyScreen(() => })) ); const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsDashboard')); +const AutomatedComplianceDashboard = lazyScreen(() => import('../screens/AutomatedComplianceDashboard')); const TrialDetailsScreen = lazyScreen(() => import('../screens/TrialDetailsScreen')); // Issue #547: GDPR @@ -617,7 +618,12 @@ const SettingsStack = () => ( + {/* Issue #547: GDPR */} | undefined; }; diff --git a/src/screens/AutomatedComplianceDashboard.tsx b/src/screens/AutomatedComplianceDashboard.tsx new file mode 100644 index 00000000..4f4442ac --- /dev/null +++ b/src/screens/AutomatedComplianceDashboard.tsx @@ -0,0 +1,442 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + TouchableOpacity, + Share, +} from 'react-native'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { useSubscriptionStore } from '../store'; +import { Card } from '../components/common/Card'; +import { ComplianceService } from '../services/complianceService'; +import { ComplianceAlert, ComplianceAuditTrailEntry } from '../types/compliance'; + +type TabType = 'dashboard' | 'monitoring' | 'alerts' | 'audit' | 'reporting'; + +const AutomatedComplianceDashboard: React.FC = () => { + const { subscriptions } = useSubscriptionStore(); + const [activeTab, setActiveTab] = useState('dashboard'); + const [alerts, setAlerts] = useState([]); + const [auditTrail, setAuditTrail] = useState([]); + const [summary, setSummary] = useState(() => ComplianceService.getDashboardSummary()); + + const handleRunChecks = () => { + const updatedSummary = ComplianceService.runAutomatedChecks(subscriptions); + setSummary(updatedSummary); + setAlerts([...ComplianceService.getAlerts()]); + setAuditTrail([...ComplianceService.getAuditTrail()]); + }; + + useEffect(() => { + handleRunChecks(); + }, [subscriptions]); + + const handleAcknowledgeAlert = (alertId: string) => { + ComplianceService.acknowledgeAlert(alertId); + setAlerts([...ComplianceService.getAlerts()]); + setSummary(ComplianceService.getDashboardSummary()); + }; + + const handleExportReport = async (format: 'csv' | 'json') => { + const reportContent = ComplianceService.generateComplianceReport(format); + try { + await Share.share({ + message: reportContent, + title: `Compliance_Report.${format}`, + }); + } catch (err) { + console.log('Report export failed', err); + } + }; + + const scoreColor = useMemo(() => { + if (summary.overallComplianceScore >= 90) return '#10B981'; + if (summary.overallComplianceScore >= 70) return '#F59E0B'; + return '#EF4444'; + }, [summary.overallComplianceScore]); + + return ( + + + {/* Header */} + + Automated Compliance + Continuous Regulatory Monitoring & Audit Trail + + + {/* Navigation Tabs */} + + {(['dashboard', 'monitoring', 'alerts', 'audit', 'reporting'] as TabType[]).map( + (tab) => ( + setActiveTab(tab)}> + + {tab.toUpperCase()} + + + ) + )} + + + {/* Score & Run Button */} + + + + Overall Compliance Score + + {summary.overallComplianceScore}% + + + + Run Checks Now + + + + Last Checked: {new Date(summary.lastRunAt).toLocaleTimeString()} + + + + {/* Dashboard Overview */} + {(activeTab === 'dashboard' || activeTab === 'monitoring') && ( + + Compliance Status Overview + + + Total Checks + {summary.totalChecksCount} + + + + Passed + + {summary.passedChecksCount} + + + + + Warnings + + {summary.warningChecksCount} + + + + + Active Alerts + + {summary.activeAlertsCount} + + + + + )} + + {/* Compliance Alerts */} + {(activeTab === 'dashboard' || activeTab === 'alerts') && ( + + Compliance Alerts + {alerts.length === 0 ? ( + + No active compliance alerts. + + ) : ( + alerts.map((alert) => ( + + + {alert.title} + {!alert.isAcknowledged && ( + handleAcknowledgeAlert(alert.id)}> + Acknowledge + + )} + + {alert.message} + + )) + )} + + )} + + {/* Audit Trail */} + {(activeTab === 'dashboard' || activeTab === 'audit') && ( + + Compliance Audit Trail + + {auditTrail.slice(0, 8).map((entry) => ( + + + {entry.action} + {entry.details} + + + {new Date(entry.timestamp).toLocaleTimeString()} + + + ))} + + + )} + + {/* Compliance Reporting */} + {(activeTab === 'dashboard' || activeTab === 'reporting') && ( + + Compliance Reporting + + Export Regulatory Reports + + Export comprehensive audit logs, rules status, and compliance summaries for external audits. + + + handleExportReport('csv')}> + Export CSV + + + handleExportReport('json')}> + Export JSON + + + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + scrollContent: { + padding: spacing.md, + }, + header: { + marginBottom: spacing.md, + }, + title: { + fontSize: typography.fontSize['2xl'], + fontWeight: typography.fontWeight.bold as any, + color: colors.textPrimary, + }, + subtitle: { + fontSize: typography.fontSize.sm, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + tabBar: { + flexDirection: 'row', + marginBottom: spacing.md, + }, + tabItem: { + paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + backgroundColor: colors.surface, + marginRight: spacing.xs, + borderWidth: 1, + borderColor: colors.border, + }, + tabItemActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + tabText: { + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.semibold as any, + color: colors.textSecondary, + }, + tabTextActive: { + color: colors.surface, + }, + scoreCard: { + padding: spacing.md, + marginBottom: spacing.lg, + }, + scoreHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + scoreLabel: { + fontSize: typography.fontSize.xs, + color: colors.textSecondary, + }, + scoreValue: { + fontSize: typography.fontSize['3xl'], + fontWeight: typography.fontWeight.bold as any, + }, + runBtn: { + backgroundColor: colors.primary, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + }, + runBtnText: { + color: colors.surface, + fontWeight: typography.fontWeight.bold as any, + fontSize: typography.fontSize.xs, + }, + lastRunText: { + fontSize: typography.fontSize.xs, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + section: { + marginBottom: spacing.xl, + }, + sectionTitle: { + fontSize: typography.fontSize.lg, + fontWeight: typography.fontWeight.bold as any, + color: colors.textPrimary, + marginBottom: spacing.md, + }, + statsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + statCard: { + width: '48%', + padding: spacing.md, + marginBottom: spacing.md, + }, + statLabel: { + fontSize: typography.fontSize.xs, + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + statValue: { + fontSize: typography.fontSize.xl, + fontWeight: typography.fontWeight.bold as any, + color: colors.textPrimary, + }, + emptyCard: { + padding: spacing.md, + alignItems: 'center', + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.fontSize.sm, + }, + alertCard: { + padding: spacing.md, + marginBottom: spacing.sm, + borderColor: '#EF4444', + borderWidth: 1, + }, + alertAcknowledged: { + opacity: 0.6, + borderColor: colors.border, + }, + alertHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + }, + alertTitle: { + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.bold as any, + color: colors.textPrimary, + flex: 1, + }, + alertMessage: { + fontSize: typography.fontSize.xs, + color: colors.textSecondary, + lineHeight: 18, + }, + ackBtn: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + paddingVertical: 2, + paddingHorizontal: spacing.xs, + borderRadius: borderRadius.sm, + }, + ackBtnText: { + fontSize: 10, + color: colors.textPrimary, + }, + cardSection: { + padding: spacing.md, + }, + auditRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.xs, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + auditMain: { + flex: 1, + marginRight: spacing.xs, + }, + auditAction: { + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.bold as any, + color: colors.textPrimary, + }, + auditDetails: { + fontSize: 11, + color: colors.textSecondary, + }, + auditTime: { + fontSize: 10, + color: colors.textSecondary, + }, + chartTitle: { + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.semibold as any, + color: colors.textPrimary, + marginBottom: spacing.xs, + }, + exportBtnRow: { + flexDirection: 'row', + marginTop: spacing.md, + justifyContent: 'space-between', + }, + exportBtn: { + flex: 0.48, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + alignItems: 'center', + }, + exportBtnText: { + color: colors.surface, + fontWeight: typography.fontWeight.bold as any, + fontSize: typography.fontSize.xs, + }, + exportBtnSecondary: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + exportBtnTextSecondary: { + color: colors.textPrimary, + fontWeight: typography.fontWeight.bold as any, + fontSize: typography.fontSize.xs, + }, +}); + +export default AutomatedComplianceDashboard; diff --git a/src/services/complianceService.ts b/src/services/complianceService.ts new file mode 100644 index 00000000..5e3c9b79 --- /dev/null +++ b/src/services/complianceService.ts @@ -0,0 +1,240 @@ +import { + ComplianceRule, + ComplianceCheckResult, + ComplianceAlert, + ComplianceAuditTrailEntry, + ComplianceDashboardSummary, +} from '../types/compliance'; +import { Subscription } from '../types/subscription'; + +export const DEFAULT_COMPLIANCE_RULES: ComplianceRule[] = [ + { + id: 'rule-gdpr-consent', + name: 'GDPR Data Subject Consent & Privacy Notice', + description: 'Ensure subscriptions collect explicit renewal consent and provide clear data processing disclosures.', + category: 'gdpr_privacy', + severity: 'high', + isEnabled: true, + regulatoryFramework: 'EU GDPR Art. 6 & 7', + }, + { + id: 'rule-auto-renewal-notice', + name: 'Mandatory Auto-Renewal Disclosure', + description: 'Subscriptions with recurring billing must explicitly disclose recurring charges and cancellation procedures.', + category: 'auto_renewal', + severity: 'critical', + isEnabled: true, + regulatoryFramework: 'California ARL / FTC Rule', + }, + { + id: 'rule-cancellation-accessibility', + name: 'Simplified One-Click Cancellation Access', + description: 'Subscribers must be provided clear, frictionless cancellation mechanisms prior to renewal billing dates.', + category: 'cancellation_policy', + severity: 'high', + isEnabled: true, + regulatoryFramework: 'EU Consumer Rights Directive', + }, + { + id: 'rule-billing-transparency', + name: 'Upfront Price & Tax Disclosure', + description: 'Billing currency, pricing details, and interval conversions must be transparently displayed.', + category: 'billing_disclosure', + severity: 'medium', + isEnabled: true, + regulatoryFramework: 'US Restore Online Shoppers Confidence Act (ROSCA)', + }, + { + id: 'rule-crypto-kyc-aml', + name: 'Crypto Stream Regulatory Compliance', + description: 'Crypto-enabled subscriptions with continuous streaming funds must adhere to token compliance checks.', + category: 'crypto_regulatory', + severity: 'medium', + isEnabled: true, + regulatoryFramework: 'FATF Travel Rule / MiCA', + }, +]; + +export class ComplianceService { + private static rules: ComplianceRule[] = [...DEFAULT_COMPLIANCE_RULES]; + private static checkResults: ComplianceCheckResult[] = []; + private static alerts: ComplianceAlert[] = []; + private static auditTrail: ComplianceAuditTrailEntry[] = []; + + /** + * Run automated compliance checks across all subscriptions + */ + public static runAutomatedChecks(subscriptions: Subscription[]): ComplianceDashboardSummary { + const results: ComplianceCheckResult[] = []; + const newAlerts: ComplianceAlert[] = []; + const now = new Date(); + + subscriptions.forEach((sub) => { + this.rules.forEach((rule) => { + if (!rule.isEnabled) return; + + let status: 'passed' | 'warning' | 'failed' = 'passed'; + let details = `Subscription ${sub.name} satisfies ${rule.name}.`; + let remediationSteps: string | undefined; + + if (rule.category === 'auto_renewal' && sub.isActive) { + const daysToBilling = Math.ceil( + (new Date(sub.nextBillingDate).getTime() - now.getTime()) / (1000 * 3600 * 24) + ); + if (daysToBilling <= 3 && !sub.notificationsEnabled) { + status = 'failed'; + details = `Upcoming auto-renewal charge in ${daysToBilling} days without user notification enabled.`; + remediationSteps = 'Enable renewal notification alerts or send manual billing reminder.'; + } + } else if (rule.category === 'crypto_regulatory' && sub.isCryptoEnabled) { + if (sub.price > 1000 && !sub.cryptoToken) { + status = 'warning'; + details = 'High-value crypto stream lacks designated verified token standard contract.'; + remediationSteps = 'Associate a verified ERC-20 / Soroban token contract address.'; + } + } else if (rule.category === 'cancellation_policy') { + if (!sub.isActive && sub.chargeCount && sub.chargeCount > 5) { + status = 'passed'; + } + } + + const checkId = `check-${sub.id}-${rule.id}-${now.getTime()}`; + const checkResult: ComplianceCheckResult = { + id: checkId, + subscriptionId: sub.id, + ruleId: rule.id, + ruleName: rule.name, + category: rule.category, + status, + severity: rule.severity, + details, + remediationSteps, + checkedAt: now.toISOString(), + }; + + results.push(checkResult); + + if (status === 'failed' || status === 'warning') { + newAlerts.push({ + id: `alert-${checkId}`, + checkId, + subscriptionId: sub.id, + title: `Compliance ${status.toUpperCase()}: ${rule.name}`, + message: details, + severity: rule.severity, + isAcknowledged: false, + createdAt: now.toISOString(), + }); + } + }); + }); + + this.checkResults = results; + this.alerts = [...newAlerts, ...this.alerts]; + + this.logAuditEntry( + 'AUTOMATED_COMPLIANCE_RUN', + 'System Compliance Engine', + 'all_subscriptions', + `Executed ${results.length} automated compliance checks for ${subscriptions.length} subscriptions.` + ); + + return this.getDashboardSummary(); + } + + /** + * Get active compliance alerts + */ + public static getAlerts(): ComplianceAlert[] { + return this.alerts; + } + + /** + * Acknowledge compliance alert + */ + public static acknowledgeAlert(alertId: string, performer = 'Compliance Officer'): void { + const alert = this.alerts.find((a) => a.id === alertId); + if (alert) { + alert.isAcknowledged = true; + this.logAuditEntry( + 'ALERT_ACKNOWLEDGED', + performer, + alertId, + `Acknowledged alert: ${alert.title}` + ); + } + } + + /** + * Get audit trail logs + */ + public static getAuditTrail(): ComplianceAuditTrailEntry[] { + return this.auditTrail; + } + + /** + * Log an audit trail entry + */ + public static logAuditEntry(action: string, performer: string, targetId: string, details: string): void { + this.auditTrail.unshift({ + id: `audit-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`, + action, + performer, + targetId, + details, + timestamp: new Date().toISOString(), + }); + } + + /** + * Get summary statistics for dashboard + */ + public static getDashboardSummary(): ComplianceDashboardSummary { + const total = this.checkResults.length; + const passed = this.checkResults.filter((r) => r.status === 'passed').length; + const failed = this.checkResults.filter((r) => r.status === 'failed').length; + const warning = this.checkResults.filter((r) => r.status === 'warning').length; + const activeAlerts = this.alerts.filter((a) => !a.isAcknowledged).length; + + const overallComplianceScore = total > 0 ? Math.round((passed / total) * 100) : 100; + + return { + overallComplianceScore, + totalChecksCount: total, + passedChecksCount: passed, + failedChecksCount: failed, + warningChecksCount: warning, + activeAlertsCount: activeAlerts, + lastRunAt: new Date().toISOString(), + }; + } + + /** + * Generate downloadable compliance report payload + */ + public static generateComplianceReport(format: 'json' | 'csv' = 'json'): string { + const summary = this.getDashboardSummary(); + if (format === 'csv') { + const headers = 'Check ID,Subscription ID,Rule,Category,Status,Severity,Details\n'; + const rows = this.checkResults + .map( + (r) => + `"${r.id}","${r.subscriptionId}","${r.ruleName}","${r.category}","${r.status}","${r.severity}","${r.details}"` + ) + .join('\n'); + return headers + rows; + } + + return JSON.stringify( + { + summary, + rules: this.rules, + recentResults: this.checkResults, + activeAlerts: this.alerts, + auditTrail: this.auditTrail, + }, + null, + 2 + ); + } +} diff --git a/src/types/api.ts b/src/types/api.ts index 1ffc1fa2..9a544f38 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -235,3 +235,6 @@ export type QueuedTransactionPayload = z.infer; export type SuperfluidStreamResult = z.infer; export type ExecuteOrQueueResult = z.infer; + +export * from './merchantAnalytics'; +export * from './compliance'; diff --git a/src/types/compliance.ts b/src/types/compliance.ts new file mode 100644 index 00000000..b60923e3 --- /dev/null +++ b/src/types/compliance.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +export const ComplianceRuleCategorySchema = z.enum([ + 'gdpr_privacy', + 'billing_disclosure', + 'auto_renewal', + 'cancellation_policy', + 'pci_dss_security', + 'crypto_regulatory', +]); + +export const ComplianceSeveritySchema = z.enum(['low', 'medium', 'high', 'critical']); + +export const ComplianceCheckStatusSchema = z.enum(['passed', 'warning', 'failed', 'pending']); + +export const ComplianceRuleSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + category: ComplianceRuleCategorySchema, + severity: ComplianceSeveritySchema, + isEnabled: z.boolean(), + regulatoryFramework: z.string(), // e.g. "GDPR", "California Senate Bill 313", "PCI-DSS v4.0", "EU Consumer Rights Directive" +}); + +export const ComplianceCheckResultSchema = z.object({ + id: z.string(), + subscriptionId: z.string(), + ruleId: z.string(), + ruleName: z.string(), + category: ComplianceRuleCategorySchema, + status: ComplianceCheckStatusSchema, + severity: ComplianceSeveritySchema, + details: z.string(), + remediationSteps: z.string().optional(), + checkedAt: z.union([z.string(), z.date()]), +}); + +export const ComplianceAlertSchema = z.object({ + id: z.string(), + checkId: z.string(), + subscriptionId: z.string(), + title: z.string(), + message: z.string(), + severity: ComplianceSeveritySchema, + isAcknowledged: z.boolean(), + createdAt: z.union([z.string(), z.date()]), +}); + +export const ComplianceAuditTrailEntrySchema = z.object({ + id: z.string(), + action: z.string(), + performer: z.string(), + targetId: z.string(), + details: z.string(), + timestamp: z.union([z.string(), z.date()]), +}); + +export const ComplianceDashboardSummarySchema = z.object({ + overallComplianceScore: z.number(), // 0 to 100 + totalChecksCount: z.number(), + passedChecksCount: z.number(), + failedChecksCount: z.number(), + warningChecksCount: z.number(), + activeAlertsCount: z.number(), + lastRunAt: z.union([z.string(), z.date()]), +}); + +export type ComplianceRuleCategory = z.infer; +export type ComplianceSeverity = z.infer; +export type ComplianceCheckStatus = z.infer; +export type ComplianceRule = z.infer; +export type ComplianceCheckResult = z.infer; +export type ComplianceAlert = z.infer; +export type ComplianceAuditTrailEntry = z.infer; +export type ComplianceDashboardSummary = z.infer;