diff --git a/src/App.tsx b/src/App.tsx index 9aabe07..e527d1c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ const Withdrawals = lazy(() => import('./modules/investment/pages/Withdrawals')) const InvestmentTransactions = lazy(() => import('./modules/investment/pages/Transactions')); const InvestmentReports = lazy(() => import('./modules/investment/pages/Reports')); const ProductDetail = lazy(() => import('./modules/investment/pages/ProductDetail')); +const KYCVerification = lazy(() => import('./modules/investment/pages/KYCVerification')); const Settings = lazy(() => import('./modules/settings/pages/Settings')); const Assistant = lazy(() => import('./modules/assistant/pages/Assistant')); @@ -108,6 +109,7 @@ function App() { } /> } /> } /> + } /> {/* Portfolio Manager */} } /> diff --git a/src/components/payments/AlternativePaymentMethods.tsx b/src/components/payments/AlternativePaymentMethods.tsx new file mode 100644 index 0000000..b7a2bcd --- /dev/null +++ b/src/components/payments/AlternativePaymentMethods.tsx @@ -0,0 +1,301 @@ +/** + * AlternativePaymentMethods Component + * Display and select alternative payment methods (OXXO, SPEI, etc.) + * Epic: OQI-005 Pagos y Stripe + */ + +import React, { useState } from 'react'; +import { + Store, + Building2, + CreditCard, + ChevronRight, + Info, + CheckCircle, + Clock, + AlertCircle, + Loader2, +} from 'lucide-react'; +import type { PaymentMethodType } from '../../types/payment.types'; + +export interface AlternativePaymentMethod { + id: PaymentMethodType; + name: string; + description: string; + icon: React.ReactNode; + processingTime: string; + available: boolean; + minAmount?: number; + maxAmount?: number; + fees?: string; + instructions?: string[]; + regions?: string[]; +} + +const ALTERNATIVE_METHODS: AlternativePaymentMethod[] = [ + { + id: 'oxxo', + name: 'OXXO Pay', + description: 'Pay with cash at any OXXO convenience store', + icon: , + processingTime: '1-3 business days', + available: true, + minAmount: 10, + maxAmount: 10000, + fees: 'No additional fees', + instructions: [ + 'Complete checkout to receive a payment voucher', + 'Visit any OXXO store and show the voucher', + 'Pay in cash at the register', + 'Your payment will be confirmed within 1-3 days', + ], + regions: ['Mexico'], + }, + { + id: 'spei', + name: 'SPEI Transfer', + description: 'Instant bank transfer via SPEI (Mexico)', + icon: , + processingTime: 'Instant - 24 hours', + available: true, + minAmount: 1, + maxAmount: 500000, + fees: 'Bank fees may apply', + instructions: [ + 'Complete checkout to receive CLABE and reference number', + 'Log into your bank app or website', + 'Make a SPEI transfer to the provided CLABE', + 'Include the reference number in your transfer', + ], + regions: ['Mexico'], + }, + { + id: 'card', + name: 'Credit/Debit Card', + description: 'Pay with Visa, Mastercard, or Amex', + icon: , + processingTime: 'Instant', + available: true, + fees: 'No additional fees', + instructions: ['Enter your card details securely'], + regions: ['Worldwide'], + }, +]; + +interface AlternativePaymentMethodsProps { + selectedMethod: PaymentMethodType | null; + onSelectMethod: (method: PaymentMethodType) => void; + onContinue: () => void; + amount?: number; + currency?: string; + isLoading?: boolean; + showCardOption?: boolean; + region?: string; +} + +const AlternativePaymentMethods: React.FC = ({ + selectedMethod, + onSelectMethod, + onContinue, + amount = 0, + currency = 'MXN', + isLoading = false, + showCardOption = true, + region = 'Mexico', +}) => { + const [expandedMethod, setExpandedMethod] = useState(null); + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency, + }).format(value); + }; + + const availableMethods = ALTERNATIVE_METHODS.filter((method) => { + if (!showCardOption && method.id === 'card') return false; + if (method.regions && !method.regions.includes(region) && !method.regions.includes('Worldwide')) { + return false; + } + if (method.minAmount && amount < method.minAmount) return false; + if (method.maxAmount && amount > method.maxAmount) return false; + return method.available; + }); + + const toggleExpand = (methodId: PaymentMethodType) => { + setExpandedMethod(expandedMethod === methodId ? null : methodId); + }; + + const getMethodStatusColor = (method: AlternativePaymentMethod) => { + if (!method.available) return 'text-gray-500'; + if (selectedMethod === method.id) return 'text-blue-400'; + return 'text-gray-400'; + }; + + return ( +
+ {/* Header */} +
+

Select Payment Method

+ {amount > 0 && ( + + Total: {formatCurrency(amount)} + + )} +
+ + {/* Payment Methods List */} +
+ {availableMethods.map((method) => { + const isSelected = selectedMethod === method.id; + const isExpanded = expandedMethod === method.id; + + return ( +
+ {/* Method Header */} + +
+ + + {/* Expanded Details */} + {isExpanded && ( +
+
+
+ Processing Time + + + {method.processingTime} + +
+ {method.fees && ( +
+ Fees + {method.fees} +
+ )} + {method.minAmount && ( +
+ Min Amount + {formatCurrency(method.minAmount)} +
+ )} + {method.maxAmount && ( +
+ Max Amount + {formatCurrency(method.maxAmount)} +
+ )} +
+ + {method.instructions && method.instructions.length > 0 && ( +
+ How it works: +
    + {method.instructions.map((instruction, index) => ( +
  1. + + {index + 1} + + {instruction} +
  2. + ))} +
+
+ )} +
+ )} +
+ ); + })} +
+ + {/* Amount Limits Warning */} + {amount > 0 && ( +
+ +

+ Some payment methods have minimum and maximum amount limits. + {selectedMethod === 'oxxo' && amount > 10000 && ( + + OXXO Pay has a maximum limit of $10,000 MXN per transaction. + + )} +

+
+ )} + + {/* Continue Button */} + {selectedMethod && ( + + )} + + {/* Security Note */} +
+ + All payments are processed securely via Stripe +
+ + ); +}; + +export default AlternativePaymentMethods; diff --git a/src/components/payments/PaymentMethodsList.tsx b/src/components/payments/PaymentMethodsList.tsx index c62dbfb..99b1037 100644 --- a/src/components/payments/PaymentMethodsList.tsx +++ b/src/components/payments/PaymentMethodsList.tsx @@ -14,16 +14,22 @@ import { CheckCircle, Loader2, Shield, + Building2, + Smartphone, + Wallet, + Store, + Banknote, } from 'lucide-react'; import { getPaymentMethods, removePaymentMethod, setDefaultPaymentMethod, } from '../../services/payment.service'; +import type { PaymentMethodType } from '../../types/payment.types'; export interface PaymentMethod { id: string; - type: 'card' | 'bank_account' | 'paypal'; + type: PaymentMethodType; isDefault: boolean; card?: { brand: string; @@ -36,6 +42,13 @@ export interface PaymentMethod { last4: string; accountType: string; }; + oxxo?: { + email: string; + }; + spei?: { + bankName: string; + clabe: string; + }; createdAt: string; } @@ -115,9 +128,80 @@ const PaymentMethodsList: React.FC = ({ } }; - const getCardIcon = (brand: string) => { - // In a real app, you'd use card brand icons - return ; + const getPaymentMethodIcon = (method: PaymentMethod) => { + switch (method.type) { + case 'card': + return ; + case 'bank_account': + case 'spei': + return ; + case 'oxxo': + return ; + case 'paypal': + return ; + case 'google_pay': + case 'apple_pay': + return ; + default: + return ; + } + }; + + const getPaymentMethodColor = (method: PaymentMethod) => { + switch (method.type) { + case 'card': + return getCardBrandColor(method.card?.brand || ''); + case 'oxxo': + return 'text-red-400'; + case 'spei': + return 'text-blue-500'; + case 'paypal': + return 'text-blue-400'; + case 'google_pay': + return 'text-green-400'; + case 'apple_pay': + return 'text-gray-300'; + case 'bank_account': + return 'text-emerald-400'; + default: + return 'text-gray-400'; + } + }; + + const getPaymentMethodLabel = (method: PaymentMethod): string => { + switch (method.type) { + case 'card': + return method.card?.brand || 'Card'; + case 'oxxo': + return 'OXXO Pay'; + case 'spei': + return 'SPEI'; + case 'paypal': + return 'PayPal'; + case 'google_pay': + return 'Google Pay'; + case 'apple_pay': + return 'Apple Pay'; + case 'bank_account': + return method.bankAccount?.bankName || 'Bank Account'; + default: + return 'Payment Method'; + } + }; + + const getPaymentMethodDetails = (method: PaymentMethod): string => { + switch (method.type) { + case 'card': + return `**** ${method.card?.last4}`; + case 'oxxo': + return method.oxxo?.email || ''; + case 'spei': + return method.spei?.clabe ? `CLABE: ****${method.spei.clabe.slice(-4)}` : ''; + case 'bank_account': + return `**** ${method.bankAccount?.last4}`; + default: + return ''; + } }; const getCardBrandColor = (brand: string) => { @@ -207,17 +291,19 @@ const PaymentMethodsList: React.FC = ({ : 'border-gray-700 hover:border-gray-600' } ${selectable && !expired ? 'cursor-pointer' : ''}`} > - {/* Card Info */} + {/* Payment Method Info */}
-
- {getCardIcon(method.card?.brand || '')} +
+ {getPaymentMethodIcon(method)}
- {method.card?.brand || method.type} + {getPaymentMethodLabel(method)} - •••• {method.card?.last4} + {getPaymentMethodDetails(method) && ( + {getPaymentMethodDetails(method)} + )} {method.isDefault && ( @@ -226,10 +312,21 @@ const PaymentMethodsList: React.FC = ({ )}
- - {expired ? 'Expired' : `Expires ${method.card?.expMonth}/${method.card?.expYear}`} - - {expiringSoon && !expired && ( + {method.type === 'card' && method.card && ( + + {expired ? 'Expired' : `Expires ${method.card.expMonth}/${method.card.expYear}`} + + )} + {method.type === 'oxxo' && ( + Pay at any OXXO store + )} + {method.type === 'spei' && ( + Bank transfer (Mexico) + )} + {method.type === 'bank_account' && method.bankAccount && ( + {method.bankAccount.accountType} + )} + {expiringSoon && !expired && method.type === 'card' && ( (expiring soon) )}
diff --git a/src/components/payments/index.ts b/src/components/payments/index.ts index 1b87062..20951e6 100644 --- a/src/components/payments/index.ts +++ b/src/components/payments/index.ts @@ -41,6 +41,10 @@ export type { RefundEligibility, RefundRequestData, RefundReason } from './Refun export { default as RefundList } from './RefundList'; export type { Refund, RefundStatus } from './RefundList'; +// Alternative Payment Methods (OQI-005) +export { default as AlternativePaymentMethods } from './AlternativePaymentMethods'; +export type { AlternativePaymentMethod } from './AlternativePaymentMethods'; + // Stripe Integration (OQI-005) export { default as StripeElementsWrapper, withStripeElements, useStripeAvailable } from './StripeElementsWrapper'; export type { StripeConfig } from './StripeElementsWrapper'; diff --git a/src/modules/investment/components/DepositForm.tsx b/src/modules/investment/components/DepositForm.tsx index c217067..ea7fc7e 100644 --- a/src/modules/investment/components/DepositForm.tsx +++ b/src/modules/investment/components/DepositForm.tsx @@ -232,17 +232,20 @@ function DepositFormInner({ accounts, onSuccess, onCancel }: DepositFormProps) {

{errors.amount.message}

)}
- {[100, 500, 1000, 5000].map((amount) => ( + {[100, 500, 1000, 5000].map((quickAmount) => ( ))}
diff --git a/src/modules/investment/components/KYCStatusBadge.tsx b/src/modules/investment/components/KYCStatusBadge.tsx new file mode 100644 index 0000000..e0a88cf --- /dev/null +++ b/src/modules/investment/components/KYCStatusBadge.tsx @@ -0,0 +1,101 @@ +/** + * KYCStatusBadge Component + * Displays the current KYC verification status + */ + +import React from 'react'; +import { CheckCircle, Clock, XCircle, AlertTriangle, Shield } from 'lucide-react'; + +export type KYCStatus = 'not_started' | 'pending' | 'in_review' | 'approved' | 'rejected'; + +interface KYCStatusBadgeProps { + status: KYCStatus; + rejectionReason?: string; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; +} + +const statusConfig: Record = { + not_started: { + icon: Shield, + label: 'No Iniciado', + color: 'text-gray-500', + bgColor: 'bg-gray-100 dark:bg-gray-800', + description: 'Completa la verificación de identidad para habilitar retiros', + }, + pending: { + icon: Clock, + label: 'Pendiente', + color: 'text-yellow-500', + bgColor: 'bg-yellow-100 dark:bg-yellow-900/30', + description: 'Tu documentación ha sido enviada y está pendiente de revisión', + }, + in_review: { + icon: Clock, + label: 'En Revisión', + color: 'text-blue-500', + bgColor: 'bg-blue-100 dark:bg-blue-900/30', + description: 'Nuestro equipo está revisando tu documentación', + }, + approved: { + icon: CheckCircle, + label: 'Verificado', + color: 'text-green-500', + bgColor: 'bg-green-100 dark:bg-green-900/30', + description: 'Tu identidad ha sido verificada exitosamente', + }, + rejected: { + icon: XCircle, + label: 'Rechazado', + color: 'text-red-500', + bgColor: 'bg-red-100 dark:bg-red-900/30', + description: 'La verificación fue rechazada. Por favor, revisa el motivo', + }, +}; + +export const KYCStatusBadge: React.FC = ({ + status, + rejectionReason, + size = 'md', + showLabel = true, +}) => { + const config = statusConfig[status]; + const Icon = config.icon; + + const sizeClasses = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-3 py-1 text-sm', + lg: 'px-4 py-2 text-base', + }; + + const iconSizes = { + sm: 'w-3 h-3', + md: 'w-4 h-4', + lg: 'w-5 h-5', + }; + + return ( +
+ + + {showLabel && config.label} + + {status === 'rejected' && rejectionReason && ( +
+ + {rejectionReason} +
+ )} +
+ ); +}; + +export default KYCStatusBadge; diff --git a/src/modules/investment/components/KYCVerificationPanel.tsx b/src/modules/investment/components/KYCVerificationPanel.tsx new file mode 100644 index 0000000..dea0870 --- /dev/null +++ b/src/modules/investment/components/KYCVerificationPanel.tsx @@ -0,0 +1,611 @@ +/** + * KYCVerificationPanel Component + * Complete KYC verification flow with document upload + * Epic: OQI-004 Cuentas de Inversion + */ + +import React, { useState, useRef } from 'react'; +import { + Shield, + User, + FileText, + Upload, + CheckCircle, + AlertCircle, + Camera, + X, + Loader2, + Clock, + MapPin, + Briefcase, +} from 'lucide-react'; +import { KYCStatusBadge, KYCStatus } from './KYCStatusBadge'; + +interface PersonalData { + fullName: string; + dateOfBirth: string; + nationality: string; + address: string; + city: string; + state: string; + postalCode: string; + country: string; + occupation: string; + incomeSource: string; +} + +interface Document { + id: string; + type: 'id_front' | 'id_back' | 'proof_of_address' | 'selfie'; + fileName: string; + status: 'pending' | 'approved' | 'rejected'; + previewUrl?: string; + rejectionReason?: string; +} + +interface KYCVerificationPanelProps { + status: KYCStatus; + personalData?: Partial; + documents?: Document[]; + rejectionReason?: string; + onSubmit?: (data: { personalData: PersonalData; documents: File[] }) => Promise; + onCancel?: () => void; +} + +type Step = 'personal' | 'documents' | 'review'; + +const DOCUMENT_TYPES = [ + { id: 'id_front', label: 'ID Frontal', description: 'INE, Pasaporte o Licencia de conducir (frente)', icon: FileText }, + { id: 'id_back', label: 'ID Reverso', description: 'Parte posterior del documento', icon: FileText }, + { id: 'proof_of_address', label: 'Comprobante de Domicilio', description: 'Recibo de luz, agua o estado de cuenta (no mayor a 3 meses)', icon: MapPin }, + { id: 'selfie', label: 'Selfie con ID', description: 'Foto tuya sosteniendo tu identificación', icon: Camera }, +]; + +const NATIONALITY_OPTIONS = [ + 'Mexico', 'United States', 'Canada', 'Spain', 'Argentina', + 'Colombia', 'Chile', 'Peru', 'Brazil', 'United Kingdom', 'Germany', 'France', +]; + +const OCCUPATION_OPTIONS = [ + 'Empleado', 'Empresario', 'Profesionista Independiente', 'Comerciante', + 'Estudiante', 'Jubilado', 'Otro', +]; + +const INCOME_SOURCE_OPTIONS = [ + 'Salario', 'Negocio Propio', 'Inversiones', 'Herencia', + 'Ahorros', 'Pensión', 'Otro', +]; + +export const KYCVerificationPanel: React.FC = ({ + status, + personalData: initialPersonalData, + documents: existingDocuments = [], + rejectionReason, + onSubmit, + onCancel, +}) => { + const [currentStep, setCurrentStep] = useState(status === 'not_started' ? 'personal' : 'review'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const [currentDocType, setCurrentDocType] = useState(null); + + const [personalData, setPersonalData] = useState({ + fullName: initialPersonalData?.fullName || '', + dateOfBirth: initialPersonalData?.dateOfBirth || '', + nationality: initialPersonalData?.nationality || '', + address: initialPersonalData?.address || '', + city: initialPersonalData?.city || '', + state: initialPersonalData?.state || '', + postalCode: initialPersonalData?.postalCode || '', + country: initialPersonalData?.country || '', + occupation: initialPersonalData?.occupation || '', + incomeSource: initialPersonalData?.incomeSource || '', + }); + + const [uploadedDocuments, setUploadedDocuments] = useState>(new Map()); + + const handlePersonalDataChange = (field: keyof PersonalData, value: string) => { + setPersonalData(prev => ({ ...prev, [field]: value })); + }; + + const isPersonalDataValid = () => { + return ( + personalData.fullName.length >= 3 && + personalData.dateOfBirth !== '' && + personalData.nationality !== '' && + personalData.address.length >= 5 && + personalData.city.length >= 2 && + personalData.country !== '' && + personalData.occupation !== '' && + personalData.incomeSource !== '' + ); + }; + + const handleFileSelect = (docType: string) => { + setCurrentDocType(docType); + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !currentDocType) return; + + if (!file.type.startsWith('image/') && file.type !== 'application/pdf') { + setError('Solo se permiten imágenes o archivos PDF'); + return; + } + + if (file.size > 10 * 1024 * 1024) { + setError('El archivo no debe exceder 10MB'); + return; + } + + const preview = URL.createObjectURL(file); + setUploadedDocuments(prev => { + const newMap = new Map(prev); + const existing = prev.get(currentDocType); + if (existing?.preview) { + URL.revokeObjectURL(existing.preview); + } + newMap.set(currentDocType, { file, preview }); + return newMap; + }); + + setError(null); + setCurrentDocType(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const removeDocument = (docType: string) => { + setUploadedDocuments(prev => { + const newMap = new Map(prev); + const existing = prev.get(docType); + if (existing?.preview) { + URL.revokeObjectURL(existing.preview); + } + newMap.delete(docType); + return newMap; + }); + }; + + const isDocumentsComplete = () => { + return DOCUMENT_TYPES.every(doc => uploadedDocuments.has(doc.id) || existingDocuments.some(d => d.type === doc.id && d.status === 'approved')); + }; + + const handleSubmit = async () => { + if (!onSubmit) return; + + setIsSubmitting(true); + setError(null); + + try { + const files = Array.from(uploadedDocuments.values()).map(d => d.file); + await onSubmit({ personalData, documents: files }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al enviar la verificación'); + } finally { + setIsSubmitting(false); + } + }; + + const renderStepIndicator = () => ( +
+ {(['personal', 'documents', 'review'] as Step[]).map((step, index) => { + const isActive = step === currentStep; + const isCompleted = + (step === 'personal' && (currentStep === 'documents' || currentStep === 'review')) || + (step === 'documents' && currentStep === 'review'); + + return ( + +
+
+ {isCompleted ? : index + 1} +
+ + {step === 'personal' ? 'Datos' : step === 'documents' ? 'Documentos' : 'Revisión'} + +
+ {index < 2 && ( +
+ )} + + ); + })} +
+ ); + + const renderPersonalDataStep = () => ( +
+
+
+ + Información Personal +
+

+ Proporciona tus datos personales como aparecen en tu identificación oficial. +

+
+ +
+
+ + handlePersonalDataChange('fullName', e.target.value)} + placeholder="Como aparece en tu identificación" + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + handlePersonalDataChange('dateOfBirth', e.target.value)} + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + +
+ +
+ + handlePersonalDataChange('address', e.target.value)} + placeholder="Calle y número" + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + handlePersonalDataChange('city', e.target.value)} + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + handlePersonalDataChange('state', e.target.value)} + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + handlePersonalDataChange('postalCode', e.target.value)} + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ); + + const renderDocumentsStep = () => ( +
+
+
+ + Documentos Requeridos +
+

+ Sube fotos claras de tus documentos. Asegúrate de que toda la información sea legible. +

+
+ + + +
+ {DOCUMENT_TYPES.map((docType) => { + const uploaded = uploadedDocuments.get(docType.id); + const existing = existingDocuments.find(d => d.type === docType.id); + const Icon = docType.icon; + + return ( +
+
+
+
+ +
+
+

{docType.label}

+

{docType.description}

+ {existing?.status === 'rejected' && existing.rejectionReason && ( +

+ + {existing.rejectionReason} +

+ )} +
+
+ +
+ {uploaded && ( + <> + Preview + + + )} + {!uploaded && existing?.status === 'approved' && ( + + )} + {!uploaded && (!existing || existing.status === 'rejected') && ( + + )} +
+
+
+ ); + })} +
+
+ ); + + const renderReviewStep = () => ( +
+
+ +

+ {status === 'pending' && 'Tu documentación ha sido enviada y está pendiente de revisión.'} + {status === 'in_review' && 'Nuestro equipo está revisando tu documentación.'} + {status === 'approved' && 'Tu identidad ha sido verificada exitosamente.'} + {status === 'rejected' && 'Por favor, corrige los documentos rechazados y vuelve a enviar.'} +

+
+ + {status === 'approved' && ( +
+ +

Verificación Completa

+

+ Ya puedes realizar retiros y acceder a todas las funcionalidades de inversión. +

+
+ )} + + {(status === 'pending' || status === 'in_review') && ( +
+ +

En Proceso

+

+ La verificación puede tomar entre 24 y 48 horas. Te notificaremos cuando esté lista. +

+
+ )} + + {status === 'rejected' && ( +
+ +

Verificación Rechazada

+ {rejectionReason && ( +

{rejectionReason}

+ )} + +
+ )} + + {personalData.fullName && ( +
+

Datos Personales

+
+
+ Nombre: + {personalData.fullName} +
+
+ Nacionalidad: + {personalData.nationality} +
+
+ Dirección: + + {personalData.address}, {personalData.city}, {personalData.country} + +
+
+
+ )} +
+ ); + + return ( +
+
+

+ + Verificación de Identidad (KYC) +

+

+ Completa la verificación para habilitar retiros y acceder a todas las funcionalidades +

+
+ + {status === 'not_started' || status === 'rejected' ? ( + <> +
+ {renderStepIndicator()} +
+ +
+ {currentStep === 'personal' && renderPersonalDataStep()} + {currentStep === 'documents' && renderDocumentsStep()} + {currentStep === 'review' && renderReviewStep()} +
+ + {error && ( +
+
+

{error}

+
+
+ )} + +
+ + + {currentStep === 'review' ? ( + + ) : ( + + )} +
+ + ) : ( +
+ {renderReviewStep()} +
+ )} +
+ ); +}; + +export default KYCVerificationPanel; diff --git a/src/modules/investment/components/WithdrawForm.tsx b/src/modules/investment/components/WithdrawForm.tsx index 898ca45..4a80f4e 100644 --- a/src/modules/investment/components/WithdrawForm.tsx +++ b/src/modules/investment/components/WithdrawForm.tsx @@ -215,8 +215,11 @@ export function WithdrawForm({ accounts, onSuccess, onCancel }: WithdrawFormProp + const [timeframe, setTimeframe] = useState('1M'); + const [performanceData, setPerformanceData] = useState([]); + const [loadingPerformance, setLoadingPerformance] = useState(false); + + const timeframes: { key: TimeframeKey; label: string }[] = [ + { key: '1W', label: '1S' }, + { key: '1M', label: '1M' }, + { key: '3M', label: '3M' }, + { key: '6M', label: '6M' }, + { key: '1Y', label: '1A' }, + { key: 'ALL', label: 'Todo' }, + ]; + + useEffect(() => { + fetchAccounts(); + fetchAccountSummary(); + }, [fetchAccounts, fetchAccountSummary]); + + useEffect(() => { + generateMockPerformanceData(); + }, [timeframe, accountSummary]); + + const generateMockPerformanceData = () => { + if (!accountSummary || accountSummary.totalBalance === 0) { + setPerformanceData([]); + return; + } + + setLoadingPerformance(true); + + const daysMap: Record = { + '1W': 7, + '1M': 30, + '3M': 90, + '6M': 180, + '1Y': 365, + ALL: 730, + }; + + const days = daysMap[timeframe]; + const data: PerformancePoint[] = []; + const baseValue = accountSummary.totalDeposited; + const currentValue = accountSummary.totalBalance; + const growthRate = (currentValue - baseValue) / baseValue; + + for (let i = days; i >= 0; i--) { + const date = new Date(); + date.setDate(date.getDate() - i); + const progress = (days - i) / days; + const randomVariation = (Math.random() - 0.5) * 0.02; + const value = baseValue * (1 + growthRate * progress + randomVariation); + + data.push({ + date: date.toISOString(), + value: Math.max(0, value), + }); + } + + data[data.length - 1].value = currentValue; + + setPerformanceData(data); + setLoadingPerformance(false); + }; + + const handleRefresh = () => { + fetchAccounts(); + fetchAccountSummary(); + }; + + const activeAccounts = accounts.filter((a) => a.status === 'active'); + const hasAccounts = accounts.length > 0; + const isLoading = loadingAccounts || loadingSummary; + + const totalReturn = accountSummary ? accountSummary.overallReturnPercent : 0; + const isPositiveReturn = totalReturn >= 0; + + return ( +
+ {/* Header */} +
+
+

Dashboard de Inversion

+

Gestiona tus cuentas de inversion con agentes IA

+
+
+ + + + Nueva Inversion +
- {/* Available Products */} -
-

Productos Disponibles

-
- {products.map((product) => ( -
-
- -
- -

{product.name}

-

{product.description}

- -
-
- Agente: - {product.agent} -
-
- Perfil: - {product.profile} -
-
- Target mensual: - {product.targetReturn} -
-
- Max Drawdown: - {product.maxDrawdown} -
-
- - + {/* Account Summary Cards */} +
+
+
+
+
- ))} + Valor Total +
+
+ ${(accountSummary?.totalBalance || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })} +
+
+ +
+
+
+ +
+ Total Invertido +
+
+ ${(accountSummary?.totalDeposited || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })} +
+
+ +
+
+
+ +
+ Rendimiento Total +
+
+ {isPositiveReturn ? '+' : ''}{totalReturn.toFixed(2)}% +
+
+ {isPositiveReturn ? '+' : ''}${Math.abs(accountSummary?.overallReturn || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })} +
+
+ +
+
+
+ +
+ Cuentas Activas +
+
{activeAccounts.length}
+
de {accounts.length} total
+
+
+ + {/* Performance Chart */} +
+
+

Rendimiento del Portfolio

+
+ {timeframes.map((tf) => ( + + ))} +
+
+ + {loadingPerformance ? ( +
+ +
+ ) : ( + + )} +
+ + {/* My Accounts Section */} +
+
+

Mis Cuentas

+ {hasAccounts && ( + + Ver todas + + )} +
+ + {isLoading ? ( +
+ +
+ ) : hasAccounts ? ( +
+ {activeAccounts.slice(0, 3).map((account) => ( + + ))} +
+ ) : ( +
+ +

No tienes cuentas de inversion activas

+

Comienza a invertir con nuestros agentes de IA

+ + + Abrir Nueva Cuenta + +
+ )} +
+ + {/* Quick Actions */} +
+

Acciones Rapidas

+
+ } + label="Depositar" + description="Agregar fondos a tus cuentas" + to="/investment/portfolio" + color="bg-blue-500/20" + /> + } + label="Retirar" + description="Solicitar retiro de fondos" + to="/investment/withdrawals" + color="bg-purple-500/20" + /> + } + label="Ver Productos" + description="Explorar opciones de inversion" + to="/investment/products" + color="bg-emerald-500/20" + />
{/* Risk Warning */} -
+

- Aviso de Riesgo: El trading e inversión conlleva riesgos significativos. - Los rendimientos objetivo no están garantizados. Puede perder parte o la totalidad de su inversión. + Aviso de Riesgo: El trading e inversion conlleva riesgos significativos. + Los rendimientos objetivo no estan garantizados. Puede perder parte o la totalidad de su inversion. + Solo invierta capital que pueda permitirse perder.

diff --git a/src/modules/investment/pages/KYCVerification.tsx b/src/modules/investment/pages/KYCVerification.tsx new file mode 100644 index 0000000..31302b5 --- /dev/null +++ b/src/modules/investment/pages/KYCVerification.tsx @@ -0,0 +1,158 @@ +/** + * KYCVerification Page + * Standalone page for KYC verification process + * Epic: OQI-004 Cuentas de Inversion + */ + +import React, { useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { ArrowLeft, AlertCircle } from 'lucide-react'; +import { KYCVerificationPanel } from '../components/KYCVerificationPanel'; +import { KYCStatus } from '../components/KYCStatusBadge'; + +interface KYCData { + status: KYCStatus; + personalData?: { + fullName: string; + dateOfBirth: string; + nationality: string; + address: string; + city: string; + state: string; + postalCode: string; + country: string; + occupation: string; + incomeSource: string; + }; + documents?: Array<{ + id: string; + type: 'id_front' | 'id_back' | 'proof_of_address' | 'selfie'; + fileName: string; + status: 'pending' | 'approved' | 'rejected'; + rejectionReason?: string; + }>; + rejectionReason?: string; +} + +export const KYCVerification: React.FC = () => { + const navigate = useNavigate(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [kycData, setKycData] = useState({ + status: 'not_started', + }); + + useEffect(() => { + loadKYCStatus(); + }, []); + + const loadKYCStatus = async () => { + try { + setLoading(true); + setError(null); + + const token = localStorage.getItem('token'); + if (!token) { + navigate('/login'); + return; + } + + const response = await fetch('/api/v1/investment/kyc/status', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setKycData(data.data || { status: 'not_started' }); + } else if (response.status === 404) { + setKycData({ status: 'not_started' }); + } else { + throw new Error('Error loading KYC status'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Error loading KYC status'); + setKycData({ status: 'not_started' }); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (data: { personalData: { fullName: string; dateOfBirth: string; nationality: string; address: string; city: string; state: string; postalCode: string; country: string; occupation: string; incomeSource: string }; documents: File[] }) => { + const token = localStorage.getItem('token'); + if (!token) { + throw new Error('Please log in to continue'); + } + + const formData = new FormData(); + formData.append('personalData', JSON.stringify(data.personalData)); + data.documents.forEach((file, index) => { + formData.append(`document_${index}`, file); + }); + + const response = await fetch('/api/v1/investment/kyc/submit', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to submit KYC'); + } + + setKycData(prev => ({ ...prev, status: 'pending' })); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+ + + +
+

+ Verificación de Identidad +

+

+ Completa tu verificación KYC para habilitar retiros +

+
+
+ + {error && ( +
+
+ +

{error}

+
+
+ )} + + navigate('/investment/portfolio')} + /> +
+ ); +}; + +export default KYCVerification; diff --git a/src/modules/investment/pages/Reports.tsx b/src/modules/investment/pages/Reports.tsx index 29374c1..a6f072a 100644 --- a/src/modules/investment/pages/Reports.tsx +++ b/src/modules/investment/pages/Reports.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useState, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { ArrowLeft, TrendingUp, TrendingDown, PieChart, BarChart3, Download, Calendar, AlertCircle } from 'lucide-react'; +import { ArrowLeft, TrendingUp, TrendingDown, PieChart, BarChart3, Download, Calendar, AlertCircle, FileText, FileSpreadsheet, ChevronDown } from 'lucide-react'; import investmentService, { AccountSummary, InvestmentAccount } from '../../../services/investment.service'; // ============================================================================ @@ -169,7 +169,147 @@ export const Reports: React.FC = () => { }; }) || []; - const handleExport = () => { + const [showExportMenu, setShowExportMenu] = useState(false); + + const handleExportCSV = () => { + if (!summary) return; + + const headers = ['Cuenta', 'Balance', 'Invertido', 'Retirado', 'Ganancias', 'Retorno %']; + const rows = summary.accounts.map(a => { + const totalReturn = a.balance - a.totalDeposited + a.totalWithdrawn; + const returnPercent = a.totalDeposited > 0 ? (totalReturn / a.totalDeposited) * 100 : 0; + return [ + a.product.name, + a.balance.toFixed(2), + a.totalDeposited.toFixed(2), + a.totalWithdrawn.toFixed(2), + a.totalEarnings.toFixed(2), + returnPercent.toFixed(2) + '%', + ]; + }); + + const summaryRow = [ + 'TOTAL', + summary.totalBalance.toFixed(2), + summary.totalDeposited.toFixed(2), + summary.totalWithdrawn.toFixed(2), + summary.overallReturn.toFixed(2), + summary.overallReturnPercent.toFixed(2) + '%', + ]; + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.join(',')), + '', + summaryRow.join(','), + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `investment-report-${new Date().toISOString().split('T')[0]}.csv`; + link.click(); + URL.revokeObjectURL(url); + setShowExportMenu(false); + }; + + const handleExportPDF = () => { + if (!summary) return; + + const printWindow = window.open('', '_blank'); + if (!printWindow) return; + + const htmlContent = ` + + + + Investment Report - ${new Date().toLocaleDateString()} + + + +

Investment Report

+

Generated: ${new Date().toLocaleString()}

+ +
+
+

Total Balance

+

$${summary.totalBalance.toLocaleString(undefined, { minimumFractionDigits: 2 })}

+
+
+

Total Invested

+

$${summary.totalDeposited.toLocaleString(undefined, { minimumFractionDigits: 2 })}

+
+
+

Overall Return

+

+ ${summary.overallReturn >= 0 ? '+' : ''}$${Math.abs(summary.overallReturn).toLocaleString(undefined, { minimumFractionDigits: 2 })} + (${summary.overallReturnPercent >= 0 ? '+' : ''}${summary.overallReturnPercent.toFixed(2)}%) +

+
+
+ +

Account Details

+ + + + + + + + + + + + ${summary.accounts.map(a => { + const totalReturn = a.balance - a.totalDeposited + a.totalWithdrawn; + const returnPercent = a.totalDeposited > 0 ? (totalReturn / a.totalDeposited) * 100 : 0; + return ` + + + + + + + + `; + }).join('')} + +
AccountBalanceInvestedEarningsReturn
${a.product.name}$${a.balance.toLocaleString(undefined, { minimumFractionDigits: 2 })}$${a.totalDeposited.toLocaleString(undefined, { minimumFractionDigits: 2 })} + ${totalReturn >= 0 ? '+' : ''}$${Math.abs(totalReturn).toLocaleString(undefined, { minimumFractionDigits: 2 })} + + ${returnPercent >= 0 ? '+' : ''}${returnPercent.toFixed(2)}% +
+ + + + + `; + + printWindow.document.write(htmlContent); + printWindow.document.close(); + printWindow.print(); + setShowExportMenu(false); + }; + + const handleExportJSON = () => { if (!summary) return; const reportData = { @@ -197,6 +337,7 @@ export const Reports: React.FC = () => { link.download = `investment-report-${new Date().toISOString().split('T')[0]}.json`; link.click(); URL.revokeObjectURL(url); + setShowExportMenu(false); }; if (loading) { @@ -246,13 +387,41 @@ export const Reports: React.FC = () => {

- +
+ + {showExportMenu && ( +
+ + + +
+ )} +
{/* Period Filter */} diff --git a/src/modules/investment/pages/Transactions.tsx b/src/modules/investment/pages/Transactions.tsx index 3c2c025..482cdd6 100644 --- a/src/modules/investment/pages/Transactions.tsx +++ b/src/modules/investment/pages/Transactions.tsx @@ -3,9 +3,9 @@ * Global transaction history across all investment accounts */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; -import { ArrowLeft, ArrowDownCircle, ArrowUpCircle, Gift, Receipt, RefreshCw, Filter, Calendar, AlertCircle } from 'lucide-react'; +import { ArrowLeft, ArrowDownCircle, ArrowUpCircle, Gift, Receipt, RefreshCw, Filter, Calendar, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import investmentService, { Transaction, InvestmentAccount } from '../../../services/investment.service'; // ============================================================================ @@ -109,6 +109,8 @@ const TransactionRow: React.FC = ({ transaction }) => { // Main Component // ============================================================================ +const ITEMS_PER_PAGE = 10; + export const Transactions: React.FC = () => { const [transactions, setTransactions] = useState([]); const [accounts, setAccounts] = useState([]); @@ -117,6 +119,7 @@ export const Transactions: React.FC = () => { const [typeFilter, setTypeFilter] = useState('all'); const [selectedAccount, setSelectedAccount] = useState('all'); const [dateRange, setDateRange] = useState<'week' | 'month' | '3months' | 'all'>('month'); + const [currentPage, setCurrentPage] = useState(1); useEffect(() => { loadData(); @@ -188,6 +191,16 @@ export const Transactions: React.FC = () => { fees: transactions.filter(t => t.type === 'fee').reduce((sum, t) => sum + t.amount, 0), }; + const totalPages = Math.ceil(transactions.length / ITEMS_PER_PAGE); + const paginatedTransactions = useMemo(() => { + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + return transactions.slice(startIndex, startIndex + ITEMS_PER_PAGE); + }, [transactions, currentPage]); + + useEffect(() => { + setCurrentPage(1); + }, [typeFilter, selectedAccount]); + return (
{/* Header */} @@ -316,9 +329,60 @@ export const Transactions: React.FC = () => {
) : (
- {transactions.map(transaction => ( + {paginatedTransactions.map(transaction => ( ))} + + {totalPages > 1 && ( +
+
+ Mostrando {((currentPage - 1) * ITEMS_PER_PAGE) + 1} - {Math.min(currentPage * ITEMS_PER_PAGE, transactions.length)} de {transactions.length} transacciones +
+
+ +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum: number; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (currentPage <= 3) { + pageNum = i + 1; + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = currentPage - 2 + i; + } + return ( + + ); + })} +
+ +
+
+ )}
)}
diff --git a/src/types/payment.types.ts b/src/types/payment.types.ts index 928866c..0d85fd2 100644 --- a/src/types/payment.types.ts +++ b/src/types/payment.types.ts @@ -145,10 +145,19 @@ export interface Payment { createdAt: string; } +export type PaymentMethodType = + | 'card' + | 'bank_account' + | 'oxxo' + | 'spei' + | 'paypal' + | 'google_pay' + | 'apple_pay'; + export interface PaymentMethod { id: string; userId: string; - type: 'card' | 'bank_account'; + type: PaymentMethodType; brand?: string; last4: string; expiryMonth?: number; @@ -156,6 +165,11 @@ export interface PaymentMethod { isDefault: boolean; stripePaymentMethodId?: string; createdAt: string; + metadata?: { + bankName?: string; + accountType?: string; + email?: string; + }; } export interface InvoiceLineItem {