From 8347c6ad488a17cd5489109bea8e2bae5113c5d6 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Mon, 26 Jan 2026 09:52:05 -0600 Subject: [PATCH] [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 --- src/components/payments/InvoicePreview.tsx | 439 +++++++++++++++ src/components/payments/RefundList.tsx | 517 ++++++++++++++++++ .../payments/RefundRequestModal.tsx | 481 ++++++++++++++++ .../payments/StripeElementsWrapper.tsx | 251 +++++++++ src/components/payments/index.ts | 13 + 5 files changed, 1701 insertions(+) create mode 100644 src/components/payments/InvoicePreview.tsx create mode 100644 src/components/payments/RefundList.tsx create mode 100644 src/components/payments/RefundRequestModal.tsx create mode 100644 src/components/payments/StripeElementsWrapper.tsx diff --git a/src/components/payments/InvoicePreview.tsx b/src/components/payments/InvoicePreview.tsx new file mode 100644 index 0000000..aebc702 --- /dev/null +++ b/src/components/payments/InvoicePreview.tsx @@ -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 ( + + {type.charAt(0).toUpperCase() + type.slice(1)} + + ); +}; + +const InvoicePreview: React.FC = ({ + 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 && ( + + )} +
+
+ ))} +
+
+ ); + }; + + // 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 && ( + + )} +
+
+ )} +
+ ); + + // 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 && ( +
+ + +
+ )} +
+ ); +}; + +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 */} +
+ + + {showStatusDropdown && ( +
+ + {Object.entries(STATUS_CONFIG).map(([status, config]) => ( + + ))} +
+ )} +
+ + {/* Export Button */} + {onExport && ( + + )} +
+ ); + + // 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 */} + + + {/* 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 && ( + + )} + + {refund.status === 'failed' && onRetryRefund && ( + + )} + + {refund.status === 'pending' && onCancelRefund && ( + + )} +
+
+ )} +
+ ); + }; + + // Render pagination + const renderPagination = () => { + if (totalPages <= 1) return null; + + return ( +
+
+ Showing {startIndex}-{endIndex} of {totalCount} refunds +
+
+ + + {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 ( + + ); + })} + + +
+
+ ); + }; + + 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. +

+ )} + +
+ ); + + // Render form step + const renderFormStep = () => ( +
+ {/* Eligibility Info */} +
+
+ +
+

Refund Window

+

+ {eligibility.daysRemaining} days remaining of {eligibility.maxRefundDays}-day policy +

+
+
+
+ + {/* 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 */} +
+ +
+ {REFUND_REASONS.map((reason) => ( +
+ + {/* Details (if required) */} + {requiresDetails && ( +
+ +