[OQI-005] feat: Add 4 advanced payment components

- StripeElementsWrapper: PCI-DSS compliance foundation with HOC and hook
- InvoicePreview: Pre-checkout invoice display with itemized breakdown
- RefundRequestModal: Modal for refund requests with validation
- RefundList: Paginated refund history with status filters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-26 09:52:05 -06:00
parent 339c645036
commit 8347c6ad48
5 changed files with 1701 additions and 0 deletions

View File

@ -0,0 +1,439 @@
/**
* InvoicePreview Component
* Pre-checkout invoice preview with itemized breakdown
* Epic: OQI-005 Pagos y Stripe
*/
import React, { useMemo } from 'react';
import {
FileText,
Calendar,
CreditCard,
Tag,
Percent,
DollarSign,
CheckCircle,
AlertCircle,
Info,
ChevronRight,
X,
Loader2,
} from 'lucide-react';
// Types
export interface InvoiceLineItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
amount: number;
type: 'subscription' | 'addon' | 'usage' | 'proration' | 'credit';
}
export interface InvoiceDiscount {
id: string;
code: string;
type: 'percentage' | 'fixed';
value: number;
amount: number;
expiresAt?: Date;
}
export interface InvoiceTax {
id: string;
name: string;
rate: number;
amount: number;
jurisdiction?: string;
}
export interface InvoicePreviewData {
id?: string;
planName: string;
planId: string;
billingCycle: 'monthly' | 'yearly';
lineItems: InvoiceLineItem[];
discounts: InvoiceDiscount[];
taxes: InvoiceTax[];
subtotal: number;
totalDiscount: number;
totalTax: number;
total: number;
currency: string;
effectiveDate: Date;
nextBillingDate: Date;
paymentMethodLast4?: string;
paymentMethodBrand?: string;
}
export interface InvoicePreviewProps {
invoice: InvoicePreviewData;
onConfirm?: () => void;
onCancel?: () => void;
onEditPayment?: () => void;
onEditBilling?: () => void;
onRemoveCoupon?: (discountId: string) => void;
isProcessing?: boolean;
showActions?: boolean;
compact?: boolean;
}
// 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: 'long',
day: 'numeric',
}).format(date);
};
// Line item type badge
const LineItemTypeBadge: React.FC<{ type: InvoiceLineItem['type'] }> = ({ type }) => {
const styles = {
subscription: 'bg-blue-900/50 text-blue-400',
addon: 'bg-purple-900/50 text-purple-400',
usage: 'bg-amber-900/50 text-amber-400',
proration: 'bg-emerald-900/50 text-emerald-400',
credit: 'bg-emerald-900/50 text-emerald-400',
};
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${styles[type]}`}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</span>
);
};
const InvoicePreview: React.FC<InvoicePreviewProps> = ({
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 = () => (
<div className="space-y-3">
<h4 className="text-slate-400 text-sm font-medium flex items-center gap-2">
<FileText className="w-4 h-4" />
Items
</h4>
<div className="space-y-2">
{invoice.lineItems.map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-2 border-b border-slate-800 last:border-0"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-white">{item.description}</span>
{!compact && <LineItemTypeBadge type={item.type} />}
</div>
{item.quantity > 1 && (
<span className="text-slate-500 text-sm">
{item.quantity} x {formatCurrency(item.unitPrice, invoice.currency)}
</span>
)}
</div>
<span className={`font-medium ${item.amount < 0 ? 'text-emerald-400' : 'text-white'}`}>
{item.amount < 0 ? '-' : ''}{formatCurrency(Math.abs(item.amount), invoice.currency)}
</span>
</div>
))}
</div>
</div>
);
// Render discounts section
const renderDiscounts = () => {
if (invoice.discounts.length === 0) return null;
return (
<div className="space-y-3">
<h4 className="text-slate-400 text-sm font-medium flex items-center gap-2">
<Tag className="w-4 h-4" />
Discounts
</h4>
<div className="space-y-2">
{invoice.discounts.map((discount) => (
<div
key={discount.id}
className="flex items-center justify-between py-2 bg-emerald-900/20 rounded-lg px-3 border border-emerald-800/50"
>
<div className="flex items-center gap-2">
<Percent className="w-4 h-4 text-emerald-400" />
<div>
<span className="text-emerald-400 font-medium">{discount.code}</span>
<span className="text-slate-400 text-sm ml-2">
({discount.type === 'percentage' ? `${discount.value}% off` : formatCurrency(discount.value, invoice.currency)})
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-emerald-400 font-medium">
-{formatCurrency(discount.amount, invoice.currency)}
</span>
{onRemoveCoupon && (
<button
onClick={() => onRemoveCoupon(discount.id)}
className="text-slate-500 hover:text-red-400 transition-colors"
title="Remove coupon"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
))}
</div>
</div>
);
};
// Render taxes section
const renderTaxes = () => {
if (invoice.taxes.length === 0) return null;
return (
<div className="space-y-2">
{invoice.taxes.map((tax) => (
<div key={tax.id} className="flex items-center justify-between text-sm">
<span className="text-slate-400">
{tax.name} ({tax.rate}%)
{tax.jurisdiction && <span className="text-slate-500"> - {tax.jurisdiction}</span>}
</span>
<span className="text-slate-300">{formatCurrency(tax.amount, invoice.currency)}</span>
</div>
))}
</div>
);
};
// Render summary section
const renderSummary = () => (
<div className="space-y-3 pt-4 border-t border-slate-700">
{/* Subtotal */}
<div className="flex justify-between">
<span className="text-slate-400">Subtotal</span>
<span className="text-white">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
{/* Discounts */}
{invoice.totalDiscount > 0 && (
<div className="flex justify-between">
<span className="text-slate-400">Discounts</span>
<span className="text-emerald-400">-{formatCurrency(invoice.totalDiscount, invoice.currency)}</span>
</div>
)}
{/* Taxes */}
{renderTaxes()}
{/* Yearly Savings */}
{yearlySavings > 0 && (
<div className="flex justify-between py-2 bg-blue-900/20 rounded-lg px-3 border border-blue-800/50">
<span className="text-blue-400 flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Yearly savings
</span>
<span className="text-blue-400 font-medium">
{formatCurrency(yearlySavings, invoice.currency)}/year
</span>
</div>
)}
{/* Total */}
<div className="flex justify-between pt-3 border-t border-slate-700">
<span className="text-white font-semibold text-lg">Total due today</span>
<span className="text-white font-bold text-xl">
{formatCurrency(invoice.total, invoice.currency)}
</span>
</div>
</div>
);
// Render billing details
const renderBillingDetails = () => (
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-700">
{/* Billing Cycle */}
<div>
<span className="text-slate-500 text-xs block mb-1">Billing Cycle</span>
<span className="text-white capitalize">{invoice.billingCycle}</span>
</div>
{/* Effective Date */}
<div>
<span className="text-slate-500 text-xs block mb-1">Effective Date</span>
<span className="text-white">{formatDate(invoice.effectiveDate)}</span>
</div>
{/* Next Billing Date */}
<div>
<span className="text-slate-500 text-xs block mb-1">Next Billing Date</span>
<span className="text-white">{formatDate(invoice.nextBillingDate)}</span>
</div>
{/* Payment Method */}
{invoice.paymentMethodLast4 && (
<div>
<span className="text-slate-500 text-xs block mb-1">Payment Method</span>
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-slate-400" />
<span className="text-white">
{invoice.paymentMethodBrand || 'Card'} ****{invoice.paymentMethodLast4}
</span>
{onEditPayment && (
<button
onClick={onEditPayment}
className="text-blue-400 text-sm hover:text-blue-300"
>
Change
</button>
)}
</div>
</div>
)}
</div>
);
// Compact view
if (compact) {
return (
<div className="bg-slate-900 rounded-lg border border-slate-800 p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-400" />
<span className="text-white font-medium">{invoice.planName}</span>
</div>
<span className="text-white font-bold">
{formatCurrency(invoice.total, invoice.currency)}
</span>
</div>
{invoice.totalDiscount > 0 && (
<div className="text-emerald-400 text-sm mb-2">
Includes {formatCurrency(invoice.totalDiscount, invoice.currency)} discount
</div>
)}
<div className="text-slate-400 text-sm flex items-center gap-1">
<Calendar className="w-4 h-4" />
Next billing: {formatDate(invoice.nextBillingDate)}
</div>
</div>
);
}
// Full view
return (
<div className="bg-slate-900 rounded-xl border border-slate-800">
{/* Header */}
<div className="p-6 border-b border-slate-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-600/20 flex items-center justify-center">
<FileText className="w-5 h-5 text-blue-400" />
</div>
<div>
<h2 className="text-white font-semibold">Invoice Preview</h2>
<p className="text-slate-400 text-sm">{invoice.planName} - {invoice.billingCycle} plan</p>
</div>
</div>
{invoice.id && (
<span className="text-slate-500 text-sm font-mono">#{invoice.id}</span>
)}
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Line Items */}
{renderLineItems()}
{/* Discounts */}
{renderDiscounts()}
{/* Summary */}
{renderSummary()}
{/* Billing Details */}
{!compact && renderBillingDetails()}
{/* Info Notice */}
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-lg">
<Info className="w-5 h-5 text-slate-400 flex-shrink-0 mt-0.5" />
<div className="text-slate-400 text-sm">
<p>
By confirming, you authorize us to charge your payment method for{' '}
<span className="text-white font-medium">
{formatCurrency(invoice.total, invoice.currency)}
</span>{' '}
today and automatically on each billing date until you cancel.
</p>
<p className="mt-2">
You can cancel anytime from your billing settings. Refunds are available
within 14 days of purchase.
</p>
</div>
</div>
</div>
{/* Actions */}
{showActions && (
<div className="p-6 border-t border-slate-800 flex justify-between">
<button
onClick={onCancel}
disabled={isProcessing}
className="px-4 py-2 text-slate-300 hover:text-white transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={isProcessing}
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"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : (
<>
<DollarSign className="w-4 h-4" />
Confirm Payment
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
)}
</div>
);
};
export default InvoicePreview;

View File

@ -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<RefundStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
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 (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
<Icon className={`w-3 h-3 ${status === 'processing' ? 'animate-spin' : ''}`} />
{config.label}
</span>
);
};
const RefundList: React.FC<RefundListProps> = ({
refunds,
totalCount,
currentPage,
pageSize,
onPageChange,
onViewInvoice,
onRetryRefund,
onCancelRefund,
onExport,
isLoading = false,
showFilters = true,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<RefundStatus | 'all'>('all');
const [expandedRefundId, setExpandedRefundId] = useState<string | null>(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 = () => (
<div className="flex flex-wrap items-center gap-4 mb-6">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</div>
</div>
{/* Status Filter */}
<div className="relative">
<button
onClick={() => 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"
>
<Filter className="w-4 h-4" />
Status: {statusFilter === 'all' ? 'All' : STATUS_CONFIG[statusFilter].label}
<ChevronDown className="w-4 h-4" />
</button>
{showStatusDropdown && (
<div className="absolute z-10 w-48 mt-2 bg-slate-800 border border-slate-700 rounded-lg shadow-xl overflow-hidden">
<button
onClick={() => {
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
</button>
{Object.entries(STATUS_CONFIG).map(([status, config]) => (
<button
key={status}
onClick={() => {
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.icon className={`w-4 h-4 ${config.color}`} />
{config.label}
</button>
))}
</div>
)}
</div>
{/* Export Button */}
{onExport && (
<button
onClick={onExport}
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"
>
<Download className="w-4 h-4" />
Export
</button>
)}
</div>
);
// Render empty state
const renderEmptyState = () => (
<div className="text-center py-12">
<RotateCcw className="w-12 h-12 text-slate-600 mx-auto mb-4" />
<h3 className="text-white font-medium mb-2">No Refunds Found</h3>
<p className="text-slate-400 text-sm">
{searchQuery || statusFilter !== 'all'
? 'Try adjusting your filters'
: "You haven't requested any refunds yet"}
</p>
</div>
);
// Render loading state
const renderLoadingState = () => (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
</div>
);
// Render refund row
const renderRefundRow = (refund: Refund) => {
const isExpanded = expandedRefundId === refund.id;
return (
<div key={refund.id} className="border-b border-slate-800 last:border-0">
{/* Main Row */}
<div
className="flex items-center gap-4 p-4 hover:bg-slate-800/50 cursor-pointer transition-colors"
onClick={() => toggleExpand(refund.id)}
>
{/* Expand Toggle */}
<button className="text-slate-500">
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{/* Date */}
<div className="w-24 text-slate-400 text-sm">
{formatDate(refund.requestedAt)}
</div>
{/* Subscription */}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{refund.subscriptionName}</p>
<p className="text-slate-500 text-xs truncate">{refund.reason}</p>
</div>
{/* Amount */}
<div className="w-28 text-right">
<p className="text-white font-medium">
{formatCurrency(refund.amount, refund.currency)}
</p>
<p className="text-slate-500 text-xs capitalize">{refund.refundMethod}</p>
</div>
{/* Status */}
<div className="w-28">
<StatusBadge status={refund.status} />
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="px-4 pb-4 ml-8 space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 bg-slate-800/50 rounded-lg">
{/* Reference ID */}
<div>
<span className="text-slate-500 text-xs block">Reference ID</span>
<span className="text-white font-mono text-sm">{refund.id}</span>
</div>
{/* Requested At */}
<div>
<span className="text-slate-500 text-xs block">Requested</span>
<span className="text-white text-sm">
{formatDate(refund.requestedAt)} at {formatTime(refund.requestedAt)}
</span>
</div>
{/* Processed At */}
{refund.processedAt && (
<div>
<span className="text-slate-500 text-xs block">Processed</span>
<span className="text-white text-sm">
{formatDate(refund.processedAt)} at {formatTime(refund.processedAt)}
</span>
</div>
)}
{/* Transaction ID */}
{refund.transactionId && (
<div>
<span className="text-slate-500 text-xs block">Transaction ID</span>
<span className="text-white font-mono text-sm">{refund.transactionId}</span>
</div>
)}
{/* Reason Details */}
{refund.reasonDetails && (
<div className="col-span-2">
<span className="text-slate-500 text-xs block mb-1">Details</span>
<p className="text-slate-300 text-sm">{refund.reasonDetails}</p>
</div>
)}
{/* Failure Reason */}
{refund.status === 'failed' && refund.failureReason && (
<div className="col-span-2">
<div className="flex items-start gap-2 p-3 bg-red-900/20 rounded-lg border border-red-800/50">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<span className="text-red-400 text-sm font-medium block">Failure Reason</span>
<span className="text-slate-400 text-sm">{refund.failureReason}</span>
</div>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-3">
{refund.invoiceId && onViewInvoice && (
<button
onClick={(e) => {
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"
>
<ExternalLink className="w-4 h-4" />
View Invoice
</button>
)}
{refund.status === 'failed' && onRetryRefund && (
<button
onClick={(e) => {
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"
>
<RotateCcw className="w-4 h-4" />
Retry Request
</button>
)}
{refund.status === 'pending' && onCancelRefund && (
<button
onClick={(e) => {
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"
>
<XCircle className="w-4 h-4" />
Cancel Request
</button>
)}
</div>
</div>
)}
</div>
);
};
// Render pagination
const renderPagination = () => {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
<div className="text-slate-400 text-sm">
Showing {startIndex}-{endIndex} of {totalCount} refunds
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange?.(currentPage - 1)}
disabled={currentPage === 1}
className="p-2 text-slate-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
{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 (
<button
key={page}
onClick={() => 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}
</button>
);
})}
<button
onClick={() => onPageChange?.(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-2 text-slate-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
);
};
return (
<div className="bg-slate-900 rounded-xl border border-slate-800">
{/* Header */}
<div className="p-6 border-b border-slate-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RotateCcw className="w-6 h-6 text-blue-400" />
<div>
<h2 className="text-white text-lg font-semibold">Refund History</h2>
<p className="text-slate-400 text-sm">{totalCount} total refund requests</p>
</div>
</div>
</div>
</div>
{/* Filters */}
{showFilters && <div className="p-6 border-b border-slate-800">{renderFilters()}</div>}
{/* Content */}
<div className="min-h-[300px]">
{isLoading && renderLoadingState()}
{!isLoading && filteredRefunds.length === 0 && renderEmptyState()}
{!isLoading && filteredRefunds.length > 0 && (
<div>{filteredRefunds.map(renderRefundRow)}</div>
)}
</div>
{/* Pagination */}
{!isLoading && filteredRefunds.length > 0 && renderPagination()}
</div>
);
};
export default RefundList;

View File

@ -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<void>;
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<RefundRequestModalProps> = ({
isOpen,
onClose,
subscriptionId,
subscriptionName,
eligibility,
onSubmit,
onSuccess,
}) => {
const [step, setStep] = useState<'form' | 'confirm' | 'success'>('form');
const [selectedReason, setSelectedReason] = useState<RefundReason | ''>('');
const [reasonDetails, setReasonDetails] = useState('');
const [refundAmount, setRefundAmount] = useState<number>(eligibility.refundableAmount);
const [refundMethod, setRefundMethod] = useState<'original' | 'wallet'>('original');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refundId, setRefundId] = useState<string | null>(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 = () => (
<div className="p-6 text-center">
<div className="w-16 h-16 rounded-full bg-red-900/20 flex items-center justify-center mx-auto mb-4">
<AlertCircle className="w-8 h-8 text-red-400" />
</div>
<h3 className="text-white text-xl font-semibold mb-2">Refund Not Available</h3>
<p className="text-slate-400 mb-4">
{eligibility.reason || 'This subscription is not eligible for a refund.'}
</p>
{eligibility.daysRemaining < 0 && (
<p className="text-slate-500 text-sm">
The {eligibility.maxRefundDays}-day refund window has expired.
</p>
)}
<button
onClick={handleClose}
className="mt-6 px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors"
>
Close
</button>
</div>
);
// Render form step
const renderFormStep = () => (
<div className="space-y-6">
{/* Eligibility Info */}
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-blue-400" />
<div>
<p className="text-blue-400 font-medium">Refund Window</p>
<p className="text-slate-400 text-sm">
{eligibility.daysRemaining} days remaining of {eligibility.maxRefundDays}-day policy
</p>
</div>
</div>
</div>
{/* Refund Amount */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
Refund Amount
</label>
{eligibility.partialRefundAllowed ? (
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="number"
value={refundAmount}
onChange={(e) => 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"
/>
</div>
) : (
<div className="bg-slate-800 border border-slate-700 rounded-lg px-4 py-3">
<span className="text-white text-lg font-semibold">
{formatCurrency(eligibility.refundableAmount)}
</span>
<span className="text-slate-500 text-sm ml-2">(Full refund)</span>
</div>
)}
<p className="text-slate-500 text-xs mt-1">
Maximum refundable: {formatCurrency(eligibility.refundableAmount)} of{' '}
{formatCurrency(eligibility.originalAmount)} original charge
</p>
</div>
{/* Refund Reason */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
Reason for Refund
</label>
<div className="space-y-2">
{REFUND_REASONS.map((reason) => (
<label
key={reason.value}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedReason === reason.value
? 'border-blue-500 bg-blue-900/20'
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
}`}
>
<input
type="radio"
name="refundReason"
value={reason.value}
checked={selectedReason === reason.value}
onChange={(e) => setSelectedReason(e.target.value as RefundReason)}
className="sr-only"
/>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
selectedReason === reason.value
? 'border-blue-500 bg-blue-500'
: 'border-slate-600'
}`}>
{selectedReason === reason.value && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
<span className="text-white">{reason.label}</span>
</label>
))}
</div>
</div>
{/* Details (if required) */}
{requiresDetails && (
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
Please provide details
<span className="text-red-400 ml-1">*</span>
</label>
<textarea
value={reasonDetails}
onChange={(e) => setReasonDetails(e.target.value)}
placeholder="Please describe your issue..."
rows={3}
minLength={10}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-white resize-none focus:border-blue-500 focus:outline-none"
/>
<p className="text-slate-500 text-xs mt-1">
Minimum 10 characters ({reasonDetails.length}/10)
</p>
</div>
)}
{/* Refund Method */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
Refund To
</label>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => 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'
}`}
>
<CreditCard className="w-5 h-5 text-slate-400 mb-2" />
<p className="text-white font-medium">Original Payment</p>
<p className="text-slate-400 text-xs">3-5 business days</p>
</button>
<button
onClick={() => 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'
}`}
>
<DollarSign className="w-5 h-5 text-slate-400 mb-2" />
<p className="text-white font-medium">Wallet Balance</p>
<p className="text-slate-400 text-xs">Instant credit</p>
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-900/20 border border-red-800/50 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400" />
<span className="text-red-400 text-sm">{error}</span>
</div>
)}
</div>
);
// Render confirm step
const renderConfirmStep = () => {
const selectedReasonLabel = REFUND_REASONS.find((r) => r.value === selectedReason)?.label;
return (
<div className="space-y-6">
<div className="text-center">
<div className="w-16 h-16 rounded-full bg-amber-900/20 flex items-center justify-center mx-auto mb-4">
<RotateCcw className="w-8 h-8 text-amber-400" />
</div>
<h3 className="text-white text-xl font-semibold">Confirm Refund Request</h3>
<p className="text-slate-400 mt-1">Please review the details below</p>
</div>
<div className="bg-slate-800/50 rounded-lg p-4 space-y-3">
<div className="flex justify-between">
<span className="text-slate-400">Subscription</span>
<span className="text-white">{subscriptionName}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Refund Amount</span>
<span className="text-white font-semibold">{formatCurrency(refundAmount)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Reason</span>
<span className="text-white">{selectedReasonLabel}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Refund To</span>
<span className="text-white capitalize">{refundMethod === 'original' ? 'Original payment' : 'Wallet'}</span>
</div>
{reasonDetails && (
<div className="pt-3 border-t border-slate-700">
<span className="text-slate-400 text-sm block mb-1">Additional Details</span>
<p className="text-slate-300 text-sm">{reasonDetails}</p>
</div>
)}
</div>
<div className="flex items-start gap-3 p-4 bg-amber-900/20 rounded-lg border border-amber-800/50">
<Info className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-slate-300 text-sm">
This action cannot be undone. Your subscription will be canceled immediately
upon refund approval.
</p>
</div>
</div>
);
};
// Render success step
const renderSuccessStep = () => (
<div className="p-6 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-900/20 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-emerald-400" />
</div>
<h3 className="text-white text-xl font-semibold mb-2">Refund Requested</h3>
<p className="text-slate-400 mb-4">
Your refund request has been submitted successfully.
</p>
<div className="bg-slate-800/50 rounded-lg p-4 mb-6">
<p className="text-slate-500 text-sm">Reference Number</p>
<p className="text-white font-mono">{refundId}</p>
</div>
<div className="space-y-2 text-left bg-slate-800/50 rounded-lg p-4 mb-6">
<p className="text-white font-medium">What happens next?</p>
<ul className="text-slate-400 text-sm space-y-1">
<li>1. Our team will review your request within 24 hours</li>
<li>2. You'll receive an email confirmation once approved</li>
<li>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'}</li>
</ul>
</div>
<button
onClick={handleClose}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
Done
</button>
</div>
);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={step === 'success' ? handleClose : undefined}
/>
{/* Modal */}
<div className="relative w-full max-w-lg bg-slate-900 rounded-xl border border-slate-800 shadow-2xl mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
{step !== 'success' && (
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<div className="flex items-center gap-3">
<RotateCcw className="w-6 h-6 text-blue-400" />
<h2 className="text-white text-lg font-semibold">Request Refund</h2>
</div>
<button
onClick={handleClose}
className="text-slate-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
)}
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[60vh]">
{!eligibility.eligible && renderNotEligible()}
{eligibility.eligible && step === 'form' && renderFormStep()}
{eligibility.eligible && step === 'confirm' && renderConfirmStep()}
{step === 'success' && renderSuccessStep()}
</div>
{/* Footer */}
{eligibility.eligible && step !== 'success' && (
<div className="flex justify-between gap-4 p-6 border-t border-slate-800">
<button
onClick={step === 'form' ? handleClose : () => setStep('form')}
disabled={isSubmitting}
className="px-4 py-2 text-slate-300 hover:text-white transition-colors disabled:opacity-50"
>
{step === 'form' ? 'Cancel' : 'Back'}
</button>
<button
onClick={step === 'form' ? () => 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 ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : step === 'form' ? (
'Continue'
) : (
'Submit Request'
)}
</button>
</div>
)}
</div>
</div>
);
};
export default RefundRequestModal;

View File

@ -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<StripeConfig>;
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 = () => (
<div className="flex flex-col items-center justify-center p-8 bg-slate-900/50 rounded-lg border border-slate-800">
<div className="relative">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
<CreditCard className="w-4 h-4 text-slate-400 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" />
</div>
<p className="text-slate-400 text-sm mt-4">Loading payment system...</p>
<div className="flex items-center gap-2 mt-2">
<ShieldCheck className="w-4 h-4 text-emerald-500" />
<span className="text-emerald-500 text-xs">Secured by Stripe</span>
</div>
</div>
);
// Error component
const ErrorState: React.FC<{ error: string; onRetry?: () => void }> = ({ error, onRetry }) => (
<div className="flex flex-col items-center justify-center p-8 bg-red-900/20 rounded-lg border border-red-800/50">
<AlertCircle className="w-8 h-8 text-red-400 mb-4" />
<p className="text-red-400 font-medium">Payment System Error</p>
<p className="text-slate-400 text-sm mt-2 text-center max-w-md">{error}</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-4 px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors"
>
Retry
</button>
)}
</div>
);
// Configuration missing component
const ConfigMissing: React.FC = () => (
<div className="flex flex-col items-center justify-center p-8 bg-amber-900/20 rounded-lg border border-amber-800/50">
<AlertCircle className="w-8 h-8 text-amber-400 mb-4" />
<p className="text-amber-400 font-medium">Stripe Configuration Missing</p>
<p className="text-slate-400 text-sm mt-2 text-center max-w-md">
The Stripe public key is not configured. Please set VITE_STRIPE_PUBLIC_KEY
in your environment variables.
</p>
<div className="mt-4 bg-slate-800 rounded p-3 font-mono text-xs text-slate-400">
VITE_STRIPE_PUBLIC_KEY=pk_test_...
</div>
</div>
);
const StripeElementsWrapper: React.FC<StripeElementsWrapperProps> = ({
children,
config = {},
clientSecret,
onReady,
onError,
showLoadingState = true,
fallbackComponent,
}) => {
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 || <ConfigMissing />;
}
// Show loading state
if (isLoading && showLoadingState) {
return fallbackComponent || <LoadingState />;
}
// Show error state
if (error) {
return fallbackComponent || <ErrorState error={error} onRetry={handleRetry} />;
}
// Render with Stripe Elements
if (!stripePromise) {
return fallbackComponent || <LoadingState />;
}
return (
<Elements stripe={stripePromise} options={elementsOptions}>
{children}
</Elements>
);
};
// Higher-order component for wrapping payment forms
export function withStripeElements<P extends object>(
WrappedComponent: React.ComponentType<P>,
config?: Partial<StripeConfig>
): React.FC<P> {
return function WithStripeElementsWrapper(props: P) {
return (
<StripeElementsWrapper config={config}>
<WrappedComponent {...props} />
</StripeElementsWrapper>
);
};
}
// 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;

View File

@ -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';