= ({
+ invoice,
+ onConfirm,
+ onCancel,
+ onEditPayment,
+ onEditBilling,
+ onRemoveCoupon,
+ isProcessing = false,
+ showActions = true,
+ compact = false,
+}) => {
+ // Calculate savings for yearly billing
+ const yearlySavings = useMemo(() => {
+ if (invoice.billingCycle === 'yearly') {
+ // Assume 20% savings on yearly plans
+ const monthlyEquivalent = invoice.subtotal / 12;
+ const monthlyCost = monthlyEquivalent * 1.25; // What it would cost monthly
+ return (monthlyCost * 12) - invoice.subtotal;
+ }
+ return 0;
+ }, [invoice.billingCycle, invoice.subtotal]);
+
+ // Render line items section
+ const renderLineItems = () => (
+
+
+
+ Items
+
+
+ {invoice.lineItems.map((item) => (
+
+
+
+ {item.description}
+ {!compact && }
+
+ {item.quantity > 1 && (
+
+ {item.quantity} x {formatCurrency(item.unitPrice, invoice.currency)}
+
+ )}
+
+
+ {item.amount < 0 ? '-' : ''}{formatCurrency(Math.abs(item.amount), invoice.currency)}
+
+
+ ))}
+
+
+ );
+
+ // Render discounts section
+ const renderDiscounts = () => {
+ if (invoice.discounts.length === 0) return null;
+
+ return (
+
+
+
+ Discounts
+
+
+ {invoice.discounts.map((discount) => (
+
+
+
+
+ {discount.code}
+
+ ({discount.type === 'percentage' ? `${discount.value}% off` : formatCurrency(discount.value, invoice.currency)})
+
+
+
+
+
+ -{formatCurrency(discount.amount, invoice.currency)}
+
+ {onRemoveCoupon && (
+ onRemoveCoupon(discount.id)}
+ className="text-slate-500 hover:text-red-400 transition-colors"
+ title="Remove coupon"
+ >
+
+
+ )}
+
+
+ ))}
+
+
+ );
+ };
+
+ // Render taxes section
+ const renderTaxes = () => {
+ if (invoice.taxes.length === 0) return null;
+
+ return (
+
+ {invoice.taxes.map((tax) => (
+
+
+ {tax.name} ({tax.rate}%)
+ {tax.jurisdiction && - {tax.jurisdiction} }
+
+ {formatCurrency(tax.amount, invoice.currency)}
+
+ ))}
+
+ );
+ };
+
+ // Render summary section
+ const renderSummary = () => (
+
+ {/* Subtotal */}
+
+ Subtotal
+ {formatCurrency(invoice.subtotal, invoice.currency)}
+
+
+ {/* Discounts */}
+ {invoice.totalDiscount > 0 && (
+
+ Discounts
+ -{formatCurrency(invoice.totalDiscount, invoice.currency)}
+
+ )}
+
+ {/* Taxes */}
+ {renderTaxes()}
+
+ {/* Yearly Savings */}
+ {yearlySavings > 0 && (
+
+
+
+ Yearly savings
+
+
+ {formatCurrency(yearlySavings, invoice.currency)}/year
+
+
+ )}
+
+ {/* Total */}
+
+ Total due today
+
+ {formatCurrency(invoice.total, invoice.currency)}
+
+
+
+ );
+
+ // Render billing details
+ const renderBillingDetails = () => (
+
+ {/* Billing Cycle */}
+
+ Billing Cycle
+ {invoice.billingCycle}
+
+
+ {/* Effective Date */}
+
+ Effective Date
+ {formatDate(invoice.effectiveDate)}
+
+
+ {/* Next Billing Date */}
+
+ Next Billing Date
+ {formatDate(invoice.nextBillingDate)}
+
+
+ {/* Payment Method */}
+ {invoice.paymentMethodLast4 && (
+
+
Payment Method
+
+
+
+ {invoice.paymentMethodBrand || 'Card'} ****{invoice.paymentMethodLast4}
+
+ {onEditPayment && (
+
+ Change
+
+ )}
+
+
+ )}
+
+ );
+
+ // Compact view
+ if (compact) {
+ return (
+
+
+
+
+ {invoice.planName}
+
+
+ {formatCurrency(invoice.total, invoice.currency)}
+
+
+
+ {invoice.totalDiscount > 0 && (
+
+ Includes {formatCurrency(invoice.totalDiscount, invoice.currency)} discount
+
+ )}
+
+
+
+ Next billing: {formatDate(invoice.nextBillingDate)}
+
+
+ );
+ }
+
+ // Full view
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
Invoice Preview
+
{invoice.planName} - {invoice.billingCycle} plan
+
+
+ {invoice.id && (
+
#{invoice.id}
+ )}
+
+
+
+ {/* Content */}
+
+ {/* Line Items */}
+ {renderLineItems()}
+
+ {/* Discounts */}
+ {renderDiscounts()}
+
+ {/* Summary */}
+ {renderSummary()}
+
+ {/* Billing Details */}
+ {!compact && renderBillingDetails()}
+
+ {/* Info Notice */}
+
+
+
+
+ By confirming, you authorize us to charge your payment method for{' '}
+
+ {formatCurrency(invoice.total, invoice.currency)}
+ {' '}
+ today and automatically on each billing date until you cancel.
+
+
+ You can cancel anytime from your billing settings. Refunds are available
+ within 14 days of purchase.
+
+
+
+
+
+ {/* Actions */}
+ {showActions && (
+
+
+ Cancel
+
+
+ {isProcessing ? (
+ <>
+
+ Processing...
+ >
+ ) : (
+ <>
+
+ Confirm Payment
+
+ >
+ )}
+
+
+ )}
+
+ );
+};
+
+export default InvoicePreview;
diff --git a/src/components/payments/RefundList.tsx b/src/components/payments/RefundList.tsx
new file mode 100644
index 0000000..01cca8e
--- /dev/null
+++ b/src/components/payments/RefundList.tsx
@@ -0,0 +1,517 @@
+/**
+ * RefundList Component
+ * Paginated list of refund requests with status tracking
+ * Epic: OQI-005 Pagos y Stripe
+ */
+
+import React, { useState, useMemo, useCallback } from 'react';
+import {
+ RotateCcw,
+ Search,
+ Filter,
+ ChevronDown,
+ ChevronUp,
+ ChevronLeft,
+ ChevronRight,
+ Clock,
+ CheckCircle,
+ XCircle,
+ AlertCircle,
+ Loader2,
+ ExternalLink,
+ Download,
+ Calendar,
+ CreditCard,
+ DollarSign,
+ MessageSquare,
+} from 'lucide-react';
+
+// Types
+export type RefundStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'canceled';
+
+export interface Refund {
+ id: string;
+ subscriptionId: string;
+ subscriptionName: string;
+ invoiceId?: string;
+ amount: number;
+ currency: string;
+ reason: string;
+ reasonDetails?: string;
+ status: RefundStatus;
+ refundMethod: 'original' | 'wallet';
+ requestedAt: Date;
+ processedAt?: Date;
+ failureReason?: string;
+ transactionId?: string;
+}
+
+export interface RefundListProps {
+ refunds: Refund[];
+ totalCount: number;
+ currentPage: number;
+ pageSize: number;
+ onPageChange?: (page: number) => void;
+ onViewInvoice?: (invoiceId: string) => void;
+ onRetryRefund?: (refundId: string) => void;
+ onCancelRefund?: (refundId: string) => void;
+ onExport?: () => void;
+ isLoading?: boolean;
+ showFilters?: boolean;
+}
+
+// Status configurations
+const STATUS_CONFIG: Record = {
+ pending: {
+ label: 'Pending',
+ color: 'text-amber-400',
+ bgColor: 'bg-amber-900/50',
+ icon: Clock,
+ },
+ processing: {
+ label: 'Processing',
+ color: 'text-blue-400',
+ bgColor: 'bg-blue-900/50',
+ icon: Loader2,
+ },
+ completed: {
+ label: 'Completed',
+ color: 'text-emerald-400',
+ bgColor: 'bg-emerald-900/50',
+ icon: CheckCircle,
+ },
+ failed: {
+ label: 'Failed',
+ color: 'text-red-400',
+ bgColor: 'bg-red-900/50',
+ icon: XCircle,
+ },
+ canceled: {
+ label: 'Canceled',
+ color: 'text-slate-400',
+ bgColor: 'bg-slate-800',
+ icon: XCircle,
+ },
+};
+
+// Currency formatter
+const formatCurrency = (amount: number, currency: string = 'USD') => {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 2,
+ }).format(amount);
+};
+
+// Date formatter
+const formatDate = (date: Date) => {
+ return new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(date);
+};
+
+// Time formatter
+const formatTime = (date: Date) => {
+ return new Intl.DateTimeFormat('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(date);
+};
+
+// Status badge component
+const StatusBadge: React.FC<{ status: RefundStatus }> = ({ status }) => {
+ const config = STATUS_CONFIG[status];
+ const Icon = config.icon;
+
+ return (
+
+
+ {config.label}
+
+ );
+};
+
+const RefundList: React.FC = ({
+ refunds,
+ totalCount,
+ currentPage,
+ pageSize,
+ onPageChange,
+ onViewInvoice,
+ onRetryRefund,
+ onCancelRefund,
+ onExport,
+ isLoading = false,
+ showFilters = true,
+}) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [expandedRefundId, setExpandedRefundId] = useState(null);
+ const [showStatusDropdown, setShowStatusDropdown] = useState(false);
+
+ // Calculate pagination
+ const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
+ const startIndex = (currentPage - 1) * pageSize + 1;
+ const endIndex = Math.min(currentPage * pageSize, totalCount);
+
+ // Filter refunds locally (for demo - in production, filtering would be server-side)
+ const filteredRefunds = useMemo(() => {
+ return refunds.filter((refund) => {
+ // Status filter
+ if (statusFilter !== 'all' && refund.status !== statusFilter) return false;
+
+ // Search filter
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase();
+ return (
+ refund.id.toLowerCase().includes(query) ||
+ refund.subscriptionName.toLowerCase().includes(query) ||
+ refund.reason.toLowerCase().includes(query)
+ );
+ }
+
+ return true;
+ });
+ }, [refunds, statusFilter, searchQuery]);
+
+ // Toggle row expansion
+ const toggleExpand = useCallback((refundId: string) => {
+ setExpandedRefundId((prev) => (prev === refundId ? null : refundId));
+ }, []);
+
+ // Render filters
+ const renderFilters = () => (
+
+ {/* Search */}
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search refunds..."
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-10 pr-4 py-2 text-white text-sm focus:border-blue-500 focus:outline-none"
+ />
+
+
+
+ {/* Status Filter */}
+
+
setShowStatusDropdown(!showStatusDropdown)}
+ className="flex items-center gap-2 px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white hover:border-slate-600 transition-colors"
+ >
+
+ Status: {statusFilter === 'all' ? 'All' : STATUS_CONFIG[statusFilter].label}
+
+
+
+ {showStatusDropdown && (
+
+ {
+ setStatusFilter('all');
+ setShowStatusDropdown(false);
+ }}
+ className={`w-full px-4 py-2 text-left text-sm hover:bg-slate-700 ${
+ statusFilter === 'all' ? 'text-blue-400' : 'text-white'
+ }`}
+ >
+ All Statuses
+
+ {Object.entries(STATUS_CONFIG).map(([status, config]) => (
+ {
+ setStatusFilter(status as RefundStatus);
+ setShowStatusDropdown(false);
+ }}
+ className={`w-full px-4 py-2 text-left text-sm hover:bg-slate-700 flex items-center gap-2 ${
+ statusFilter === status ? 'text-blue-400' : 'text-white'
+ }`}
+ >
+
+ {config.label}
+
+ ))}
+
+ )}
+
+
+ {/* Export Button */}
+ {onExport && (
+
+
+ Export
+
+ )}
+
+ );
+
+ // Render empty state
+ const renderEmptyState = () => (
+
+
+
No Refunds Found
+
+ {searchQuery || statusFilter !== 'all'
+ ? 'Try adjusting your filters'
+ : "You haven't requested any refunds yet"}
+
+
+ );
+
+ // Render loading state
+ const renderLoadingState = () => (
+
+
+
+ );
+
+ // Render refund row
+ const renderRefundRow = (refund: Refund) => {
+ const isExpanded = expandedRefundId === refund.id;
+
+ return (
+
+ {/* Main Row */}
+
toggleExpand(refund.id)}
+ >
+ {/* Expand Toggle */}
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Date */}
+
+ {formatDate(refund.requestedAt)}
+
+
+ {/* Subscription */}
+
+
{refund.subscriptionName}
+
{refund.reason}
+
+
+ {/* Amount */}
+
+
+ {formatCurrency(refund.amount, refund.currency)}
+
+
{refund.refundMethod}
+
+
+ {/* Status */}
+
+
+
+
+
+ {/* Expanded Details */}
+ {isExpanded && (
+
+
+ {/* Reference ID */}
+
+ Reference ID
+ {refund.id}
+
+
+ {/* Requested At */}
+
+ Requested
+
+ {formatDate(refund.requestedAt)} at {formatTime(refund.requestedAt)}
+
+
+
+ {/* Processed At */}
+ {refund.processedAt && (
+
+ Processed
+
+ {formatDate(refund.processedAt)} at {formatTime(refund.processedAt)}
+
+
+ )}
+
+ {/* Transaction ID */}
+ {refund.transactionId && (
+
+ Transaction ID
+ {refund.transactionId}
+
+ )}
+
+ {/* Reason Details */}
+ {refund.reasonDetails && (
+
+
Details
+
{refund.reasonDetails}
+
+ )}
+
+ {/* Failure Reason */}
+ {refund.status === 'failed' && refund.failureReason && (
+
+
+
+
+ Failure Reason
+ {refund.failureReason}
+
+
+
+ )}
+
+
+ {/* Actions */}
+
+ {refund.invoiceId && onViewInvoice && (
+ {
+ e.stopPropagation();
+ onViewInvoice(refund.invoiceId!);
+ }}
+ className="flex items-center gap-2 px-3 py-1.5 text-sm text-blue-400 hover:text-blue-300 transition-colors"
+ >
+
+ View Invoice
+
+ )}
+
+ {refund.status === 'failed' && onRetryRefund && (
+ {
+ e.stopPropagation();
+ onRetryRefund(refund.id);
+ }}
+ className="flex items-center gap-2 px-3 py-1.5 text-sm text-amber-400 hover:text-amber-300 transition-colors"
+ >
+
+ Retry Request
+
+ )}
+
+ {refund.status === 'pending' && onCancelRefund && (
+ {
+ e.stopPropagation();
+ onCancelRefund(refund.id);
+ }}
+ className="flex items-center gap-2 px-3 py-1.5 text-sm text-red-400 hover:text-red-300 transition-colors"
+ >
+
+ Cancel Request
+
+ )}
+
+
+ )}
+
+ );
+ };
+
+ // Render pagination
+ const renderPagination = () => {
+ if (totalPages <= 1) return null;
+
+ return (
+
+
+ Showing {startIndex}-{endIndex} of {totalCount} refunds
+
+
+ onPageChange?.(currentPage - 1)}
+ disabled={currentPage === 1}
+ className="p-2 text-slate-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ >
+
+
+
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
+ let page: number;
+ if (totalPages <= 5) {
+ page = i + 1;
+ } else if (currentPage <= 3) {
+ page = i + 1;
+ } else if (currentPage >= totalPages - 2) {
+ page = totalPages - 4 + i;
+ } else {
+ page = currentPage - 2 + i;
+ }
+
+ return (
+ onPageChange?.(page)}
+ className={`w-8 h-8 rounded-lg text-sm transition-colors ${
+ currentPage === page
+ ? 'bg-blue-600 text-white'
+ : 'text-slate-400 hover:text-white hover:bg-slate-800'
+ }`}
+ >
+ {page}
+
+ );
+ })}
+
+ onPageChange?.(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ className="p-2 text-slate-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ >
+
+
+
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
Refund History
+
{totalCount} total refund requests
+
+
+
+
+
+ {/* Filters */}
+ {showFilters &&
{renderFilters()}
}
+
+ {/* Content */}
+
+ {isLoading && renderLoadingState()}
+ {!isLoading && filteredRefunds.length === 0 && renderEmptyState()}
+ {!isLoading && filteredRefunds.length > 0 && (
+
{filteredRefunds.map(renderRefundRow)}
+ )}
+
+
+ {/* Pagination */}
+ {!isLoading && filteredRefunds.length > 0 && renderPagination()}
+
+ );
+};
+
+export default RefundList;
diff --git a/src/components/payments/RefundRequestModal.tsx b/src/components/payments/RefundRequestModal.tsx
new file mode 100644
index 0000000..b41d337
--- /dev/null
+++ b/src/components/payments/RefundRequestModal.tsx
@@ -0,0 +1,481 @@
+/**
+ * RefundRequestModal Component
+ * Modal for requesting refunds on subscriptions/purchases
+ * Epic: OQI-005 Pagos y Stripe
+ */
+
+import React, { useState, useMemo, useCallback } from 'react';
+import {
+ RotateCcw,
+ AlertCircle,
+ CheckCircle,
+ X,
+ Clock,
+ DollarSign,
+ Info,
+ Loader2,
+ Calendar,
+ CreditCard,
+ MessageSquare,
+} from 'lucide-react';
+
+// Types
+export interface RefundEligibility {
+ eligible: boolean;
+ refundableAmount: number;
+ originalAmount: number;
+ daysRemaining: number;
+ maxRefundDays: number;
+ partialRefundAllowed: boolean;
+ reason?: string;
+}
+
+export interface RefundRequestData {
+ subscriptionId: string;
+ amount: number;
+ reason: RefundReason;
+ reasonDetails?: string;
+ refundMethod: 'original' | 'wallet';
+}
+
+export type RefundReason =
+ | 'changed_mind'
+ | 'not_needed'
+ | 'duplicate_charge'
+ | 'service_issue'
+ | 'billing_error'
+ | 'other';
+
+export interface RefundRequestModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ subscriptionId: string;
+ subscriptionName: string;
+ eligibility: RefundEligibility;
+ onSubmit?: (data: RefundRequestData) => Promise;
+ onSuccess?: (refundId: string) => void;
+}
+
+const REFUND_REASONS: { value: RefundReason; label: string; requiresDetails: boolean }[] = [
+ { value: 'changed_mind', label: 'Changed my mind', requiresDetails: false },
+ { value: 'not_needed', label: "Don't need the service anymore", requiresDetails: false },
+ { value: 'duplicate_charge', label: 'Duplicate or incorrect charge', requiresDetails: true },
+ { value: 'service_issue', label: 'Service not working as expected', requiresDetails: true },
+ { value: 'billing_error', label: 'Billing or pricing error', requiresDetails: true },
+ { value: 'other', label: 'Other reason', requiresDetails: true },
+];
+
+// Currency formatter
+const formatCurrency = (amount: number, currency: string = 'USD') => {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 2,
+ }).format(amount);
+};
+
+const RefundRequestModal: React.FC = ({
+ isOpen,
+ onClose,
+ subscriptionId,
+ subscriptionName,
+ eligibility,
+ onSubmit,
+ onSuccess,
+}) => {
+ const [step, setStep] = useState<'form' | 'confirm' | 'success'>('form');
+ const [selectedReason, setSelectedReason] = useState('');
+ const [reasonDetails, setReasonDetails] = useState('');
+ const [refundAmount, setRefundAmount] = useState(eligibility.refundableAmount);
+ const [refundMethod, setRefundMethod] = useState<'original' | 'wallet'>('original');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [refundId, setRefundId] = useState(null);
+
+ // Check if details are required
+ const requiresDetails = useMemo(() => {
+ const reason = REFUND_REASONS.find((r) => r.value === selectedReason);
+ return reason?.requiresDetails || false;
+ }, [selectedReason]);
+
+ // Form validation
+ const isFormValid = useMemo(() => {
+ if (!selectedReason) return false;
+ if (requiresDetails && reasonDetails.trim().length < 10) return false;
+ if (refundAmount <= 0 || refundAmount > eligibility.refundableAmount) return false;
+ return true;
+ }, [selectedReason, requiresDetails, reasonDetails, refundAmount, eligibility.refundableAmount]);
+
+ // Handle amount change
+ const handleAmountChange = useCallback((value: string) => {
+ const amount = parseFloat(value) || 0;
+ setRefundAmount(Math.min(amount, eligibility.refundableAmount));
+ }, [eligibility.refundableAmount]);
+
+ // Handle submit
+ const handleSubmit = useCallback(async () => {
+ if (!isFormValid || !onSubmit) return;
+
+ setIsSubmitting(true);
+ setError(null);
+
+ try {
+ await onSubmit({
+ subscriptionId,
+ amount: refundAmount,
+ reason: selectedReason as RefundReason,
+ reasonDetails: requiresDetails ? reasonDetails : undefined,
+ refundMethod,
+ });
+
+ // Generate mock refund ID for success state
+ const mockRefundId = `ref_${Date.now().toString(36)}`;
+ setRefundId(mockRefundId);
+ setStep('success');
+ onSuccess?.(mockRefundId);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to process refund request');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [isFormValid, onSubmit, subscriptionId, refundAmount, selectedReason, requiresDetails, reasonDetails, refundMethod, onSuccess]);
+
+ // Reset state on close
+ const handleClose = useCallback(() => {
+ setStep('form');
+ setSelectedReason('');
+ setReasonDetails('');
+ setRefundAmount(eligibility.refundableAmount);
+ setRefundMethod('original');
+ setError(null);
+ setRefundId(null);
+ onClose();
+ }, [onClose, eligibility.refundableAmount]);
+
+ // Render not eligible state
+ const renderNotEligible = () => (
+
+
+
Refund Not Available
+
+ {eligibility.reason || 'This subscription is not eligible for a refund.'}
+
+ {eligibility.daysRemaining < 0 && (
+
+ The {eligibility.maxRefundDays}-day refund window has expired.
+
+ )}
+
+ Close
+
+
+ );
+
+ // Render form step
+ const renderFormStep = () => (
+
+ {/* Eligibility Info */}
+
+
+
+
+
Refund Window
+
+ {eligibility.daysRemaining} days remaining of {eligibility.maxRefundDays}-day policy
+
+
+
+
+
+ {/* Refund Amount */}
+
+
+ Refund Amount
+
+ {eligibility.partialRefundAllowed ? (
+
+
+ handleAmountChange(e.target.value)}
+ max={eligibility.refundableAmount}
+ min={0}
+ step="0.01"
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-10 pr-4 py-3 text-white focus:border-blue-500 focus:outline-none"
+ />
+
+ ) : (
+
+
+ {formatCurrency(eligibility.refundableAmount)}
+
+ (Full refund)
+
+ )}
+
+ Maximum refundable: {formatCurrency(eligibility.refundableAmount)} of{' '}
+ {formatCurrency(eligibility.originalAmount)} original charge
+
+
+
+ {/* Refund Reason */}
+
+
+ Reason for Refund
+
+
+ {REFUND_REASONS.map((reason) => (
+
+ setSelectedReason(e.target.value as RefundReason)}
+ className="sr-only"
+ />
+
+ {selectedReason === reason.value && (
+
+ )}
+
+ {reason.label}
+
+ ))}
+
+
+
+ {/* Details (if required) */}
+ {requiresDetails && (
+
+
+ Please provide details
+ *
+
+
+ )}
+
+ {/* Refund Method */}
+
+
+ Refund To
+
+
+
setRefundMethod('original')}
+ className={`p-4 rounded-lg border text-left transition-colors ${
+ refundMethod === 'original'
+ ? 'border-blue-500 bg-blue-900/20'
+ : 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
+ }`}
+ >
+
+ Original Payment
+ 3-5 business days
+
+
setRefundMethod('wallet')}
+ className={`p-4 rounded-lg border text-left transition-colors ${
+ refundMethod === 'wallet'
+ ? 'border-blue-500 bg-blue-900/20'
+ : 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
+ }`}
+ >
+
+ Wallet Balance
+ Instant credit
+
+
+
+
+ {/* Error */}
+ {error && (
+
+ )}
+
+ );
+
+ // Render confirm step
+ const renderConfirmStep = () => {
+ const selectedReasonLabel = REFUND_REASONS.find((r) => r.value === selectedReason)?.label;
+
+ return (
+
+
+
+
+
+
Confirm Refund Request
+
Please review the details below
+
+
+
+
+ Subscription
+ {subscriptionName}
+
+
+ Refund Amount
+ {formatCurrency(refundAmount)}
+
+
+ Reason
+ {selectedReasonLabel}
+
+
+ Refund To
+ {refundMethod === 'original' ? 'Original payment' : 'Wallet'}
+
+ {reasonDetails && (
+
+
Additional Details
+
{reasonDetails}
+
+ )}
+
+
+
+
+
+ This action cannot be undone. Your subscription will be canceled immediately
+ upon refund approval.
+
+
+
+ );
+ };
+
+ // Render success step
+ const renderSuccessStep = () => (
+
+
+
+
+
Refund Requested
+
+ Your refund request has been submitted successfully.
+
+
+
Reference Number
+
{refundId}
+
+
+
What happens next?
+
+ 1. Our team will review your request within 24 hours
+ 2. You'll receive an email confirmation once approved
+ 3. {refundMethod === 'original'
+ ? 'Funds will be returned to your original payment method in 3-5 business days'
+ : 'Funds will be credited to your wallet instantly'}
+
+
+
+ Done
+
+
+ );
+
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+ {/* Header */}
+ {step !== 'success' && (
+
+
+
+
Request Refund
+
+
+
+
+
+ )}
+
+ {/* Content */}
+
+ {!eligibility.eligible && renderNotEligible()}
+ {eligibility.eligible && step === 'form' && renderFormStep()}
+ {eligibility.eligible && step === 'confirm' && renderConfirmStep()}
+ {step === 'success' && renderSuccessStep()}
+
+
+ {/* Footer */}
+ {eligibility.eligible && step !== 'success' && (
+
+ setStep('form')}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate-300 hover:text-white transition-colors disabled:opacity-50"
+ >
+ {step === 'form' ? 'Cancel' : 'Back'}
+
+ setStep('confirm') : handleSubmit}
+ disabled={!isFormValid || isSubmitting}
+ className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ >
+ {isSubmitting ? (
+ <>
+
+ Processing...
+ >
+ ) : step === 'form' ? (
+ 'Continue'
+ ) : (
+ 'Submit Request'
+ )}
+
+
+ )}
+
+
+ );
+};
+
+export default RefundRequestModal;
diff --git a/src/components/payments/StripeElementsWrapper.tsx b/src/components/payments/StripeElementsWrapper.tsx
new file mode 100644
index 0000000..e8dcdcd
--- /dev/null
+++ b/src/components/payments/StripeElementsWrapper.tsx
@@ -0,0 +1,251 @@
+/**
+ * StripeElementsWrapper Component
+ * Provider component for Stripe Elements integration
+ * Epic: OQI-005 Pagos y Stripe
+ *
+ * IMPORTANT: This component is required for PCI-DSS compliance.
+ * All payment forms must be wrapped with this provider.
+ */
+
+import React, { useState, useEffect, useMemo } from 'react';
+import { Elements } from '@stripe/react-stripe-js';
+import { loadStripe, Stripe, StripeElementsOptions } from '@stripe/stripe-js';
+import {
+ CreditCard,
+ AlertCircle,
+ Loader2,
+ ShieldCheck,
+} from 'lucide-react';
+
+// Types
+export interface StripeConfig {
+ publicKey: string;
+ locale?: 'en' | 'es' | 'auto';
+ appearance?: 'stripe' | 'night' | 'flat';
+}
+
+export interface StripeElementsWrapperProps {
+ children: React.ReactNode;
+ config?: Partial;
+ clientSecret?: string;
+ onReady?: () => void;
+ onError?: (error: Error) => void;
+ showLoadingState?: boolean;
+ fallbackComponent?: React.ReactNode;
+}
+
+// Default Stripe appearance for dark theme
+const DARK_THEME_APPEARANCE: StripeElementsOptions['appearance'] = {
+ theme: 'night',
+ variables: {
+ colorPrimary: '#3b82f6',
+ colorBackground: '#1e293b',
+ colorText: '#f1f5f9',
+ colorDanger: '#ef4444',
+ fontFamily: 'Inter, system-ui, sans-serif',
+ fontSizeBase: '14px',
+ borderRadius: '8px',
+ spacingUnit: '4px',
+ },
+ rules: {
+ '.Input': {
+ backgroundColor: '#0f172a',
+ border: '1px solid #334155',
+ boxShadow: 'none',
+ },
+ '.Input:focus': {
+ borderColor: '#3b82f6',
+ boxShadow: '0 0 0 1px #3b82f6',
+ },
+ '.Input--invalid': {
+ borderColor: '#ef4444',
+ },
+ '.Label': {
+ color: '#94a3b8',
+ fontSize: '12px',
+ fontWeight: '500',
+ },
+ '.Error': {
+ color: '#ef4444',
+ fontSize: '12px',
+ },
+ },
+};
+
+// Stripe public key from environment
+const STRIPE_PUBLIC_KEY = import.meta.env.VITE_STRIPE_PUBLIC_KEY ||
+ process.env.REACT_APP_STRIPE_PUBLIC_KEY || '';
+
+// Loading component
+const LoadingState: React.FC = () => (
+
+
+
+
+
+
Loading payment system...
+
+
+ Secured by Stripe
+
+
+);
+
+// Error component
+const ErrorState: React.FC<{ error: string; onRetry?: () => void }> = ({ error, onRetry }) => (
+
+
+
Payment System Error
+
{error}
+ {onRetry && (
+
+ Retry
+
+ )}
+
+);
+
+// Configuration missing component
+const ConfigMissing: React.FC = () => (
+
+
+
Stripe Configuration Missing
+
+ The Stripe public key is not configured. Please set VITE_STRIPE_PUBLIC_KEY
+ in your environment variables.
+
+
+ VITE_STRIPE_PUBLIC_KEY=pk_test_...
+
+
+);
+
+const StripeElementsWrapper: React.FC = ({
+ children,
+ config = {},
+ clientSecret,
+ onReady,
+ onError,
+ showLoadingState = true,
+ fallbackComponent,
+}) => {
+ const [stripePromise, setStripePromise] = useState | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Merge config with defaults
+ const stripeConfig = useMemo(() => ({
+ publicKey: config.publicKey || STRIPE_PUBLIC_KEY,
+ locale: config.locale || 'auto',
+ appearance: config.appearance || 'night',
+ }), [config]);
+
+ // Initialize Stripe
+ useEffect(() => {
+ if (!stripeConfig.publicKey) {
+ setIsLoading(false);
+ setError('Stripe public key is not configured');
+ return;
+ }
+
+ const initStripe = async () => {
+ try {
+ setIsLoading(true);
+ setError(null);
+
+ const stripe = loadStripe(stripeConfig.publicKey, {
+ locale: stripeConfig.locale as 'en' | 'es' | undefined,
+ });
+
+ setStripePromise(stripe);
+
+ // Wait for Stripe to load and verify
+ const stripeInstance = await stripe;
+ if (!stripeInstance) {
+ throw new Error('Failed to initialize Stripe');
+ }
+
+ setIsLoading(false);
+ onReady?.();
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error loading Stripe';
+ setError(errorMessage);
+ setIsLoading(false);
+ onError?.(err instanceof Error ? err : new Error(errorMessage));
+ }
+ };
+
+ initStripe();
+ }, [stripeConfig.publicKey, stripeConfig.locale, onReady, onError]);
+
+ // Retry handler
+ const handleRetry = () => {
+ setError(null);
+ setIsLoading(true);
+ // Re-trigger useEffect by updating state
+ setStripePromise(null);
+ };
+
+ // Elements options
+ const elementsOptions: StripeElementsOptions = useMemo(() => ({
+ clientSecret: clientSecret || undefined,
+ appearance: DARK_THEME_APPEARANCE,
+ loader: 'auto',
+ }), [clientSecret]);
+
+ // Check for missing config
+ if (!stripeConfig.publicKey) {
+ return fallbackComponent || ;
+ }
+
+ // Show loading state
+ if (isLoading && showLoadingState) {
+ return fallbackComponent || ;
+ }
+
+ // Show error state
+ if (error) {
+ return fallbackComponent || ;
+ }
+
+ // Render with Stripe Elements
+ if (!stripePromise) {
+ return fallbackComponent || ;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+// Higher-order component for wrapping payment forms
+export function withStripeElements(
+ WrappedComponent: React.ComponentType
,
+ config?: Partial
+): React.FC {
+ return function WithStripeElementsWrapper(props: P) {
+ return (
+
+
+
+ );
+ };
+}
+
+// Hook to check if Stripe is available
+export function useStripeAvailable(): boolean {
+ const [available, setAvailable] = useState(false);
+
+ useEffect(() => {
+ setAvailable(!!STRIPE_PUBLIC_KEY);
+ }, []);
+
+ return available;
+}
+
+export default StripeElementsWrapper;
diff --git a/src/components/payments/index.ts b/src/components/payments/index.ts
index 73203a1..56f7836 100644
--- a/src/components/payments/index.ts
+++ b/src/components/payments/index.ts
@@ -1,6 +1,7 @@
/**
* Payment Components Index
* Export all payment-related components
+ * Epic: OQI-005 Pagos y Stripe
*/
export { PricingCard } from './PricingCard';
@@ -25,6 +26,8 @@ export type { Transaction, TransactionType, TransactionStatus } from './Transact
export { default as InvoiceList } from './InvoiceList';
export type { Invoice } from './InvoiceList';
export { default as InvoiceDetail } from './InvoiceDetail';
+export { default as InvoicePreview } from './InvoicePreview';
+export type { InvoiceLineItem, InvoiceDiscount, InvoiceTax, InvoicePreviewData } from './InvoicePreview';
// Payment Methods Management
export { default as PaymentMethodsList } from './PaymentMethodsList';
@@ -32,3 +35,13 @@ export type { PaymentMethod } from './PaymentMethodsList';
// Subscription Management
export { default as SubscriptionUpgradeFlow } from './SubscriptionUpgradeFlow';
+
+// Refund Components (OQI-005)
+export { default as RefundRequestModal } from './RefundRequestModal';
+export type { RefundEligibility, RefundRequestData, RefundReason } from './RefundRequestModal';
+export { default as RefundList } from './RefundList';
+export type { Refund, RefundStatus } from './RefundList';
+
+// Stripe Integration (OQI-005)
+export { default as StripeElementsWrapper, withStripeElements, useStripeAvailable } from './StripeElementsWrapper';
+export type { StripeConfig } from './StripeElementsWrapper';