/** * Transaction Detail Modal * * Displays comprehensive transaction information and provides refund functionality. * Supports both partial and full refunds with reason selection. */ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { X, CreditCard, User, Mail, Calendar, DollarSign, RefreshCcw, CheckCircle, Clock, XCircle, AlertCircle, Receipt, ExternalLink, Loader2, ArrowLeftRight, Percent, } from 'lucide-react'; import { TransactionDetail, RefundInfo, RefundRequest } from '../api/payments'; import { useTransactionDetail, useRefundTransaction } from '../hooks/useTransactionAnalytics'; import Portal from './Portal'; interface TransactionDetailModalProps { transactionId: number | null; onClose: () => void; } const TransactionDetailModal: React.FC = ({ transactionId, onClose, }) => { const { t } = useTranslation(); const { data: transaction, isLoading, error } = useTransactionDetail(transactionId); const refundMutation = useRefundTransaction(); // Refund form state const [showRefundForm, setShowRefundForm] = useState(false); const [refundType, setRefundType] = useState<'full' | 'partial'>('full'); const [refundAmount, setRefundAmount] = useState(''); const [refundReason, setRefundReason] = useState('requested_by_customer'); const [refundError, setRefundError] = useState(null); if (!transactionId) return null; const handleRefund = async () => { if (!transaction) return; setRefundError(null); const request: RefundRequest = { reason: refundReason, }; // For partial refunds, include the amount if (refundType === 'partial') { const amountCents = Math.round(parseFloat(refundAmount) * 100); if (isNaN(amountCents) || amountCents <= 0) { setRefundError(t('payments.enterValidRefundAmount')); return; } if (amountCents > transaction.refundable_amount) { setRefundError(t('payments.amountExceedsRefundable', { max: (transaction.refundable_amount / 100).toFixed(2) })); return; } request.amount = amountCents; } try { await refundMutation.mutateAsync({ transactionId: transaction.id, request, }); setShowRefundForm(false); setRefundAmount(''); } catch (err: any) { setRefundError(err.response?.data?.error || t('payments.failedToProcessRefund')); } }; // Status badge helper const getStatusBadge = (status: string) => { const styles: Record = { succeeded: { bg: 'bg-green-100', text: 'text-green-800', icon: }, pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: }, failed: { bg: 'bg-red-100', text: 'text-red-800', icon: }, refunded: { bg: 'bg-gray-100', text: 'text-gray-800', icon: }, partially_refunded: { bg: 'bg-orange-100', text: 'text-orange-800', icon: }, }; const style = styles[status] || styles.pending; return ( {style.icon} {status.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())} ); }; // Format date helper const formatDate = (dateStr: string | number) => { const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr); return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', }); }; // Format timestamp for refunds const formatRefundDate = (timestamp: number) => { const date = new Date(timestamp * 1000); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', }); }; // Get payment method display const getPaymentMethodDisplay = () => { if (!transaction?.payment_method_info) return null; const pm = transaction.payment_method_info; if (pm.type === 'card') { return (

{pm.brand} **** {pm.last4}

{pm.exp_month && pm.exp_year && (

{t('payments.expires')} {pm.exp_month}/{pm.exp_year} {pm.funding && ` (${pm.funding})`}

)}
); } return (

{pm.type.replace('_', ' ')}

{pm.bank_name &&

{pm.bank_name}

}
); }; return (
e.stopPropagation()} > {/* Header */}

{t('payments.transactionDetails')}

{transaction && (

{transaction.stripe_payment_intent_id}

)}
{/* Content */}
{isLoading && (
)} {error && (

{t('payments.failedToLoadTransaction')}

)} {transaction && ( <> {/* Status & Amount */}
{getStatusBadge(transaction.status)}

{transaction.amount_display}

{transaction.transaction_type.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())}

{transaction.can_refund && !showRefundForm && ( )}
{/* Refund Form */} {showRefundForm && (

{t('payments.issueRefund')}

{/* Refund Type */}
{/* Partial Amount */} {refundType === 'partial' && (
$ setRefundAmount(e.target.value)} placeholder="0.00" className="w-full pl-7 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500" />
)} {/* Reason */}
{refundError && (
{refundError}
)} {/* Actions */}
)} {/* Details Grid */}
{/* Customer Info */}

{t('payments.customer')}

{transaction.customer_name && (
{transaction.customer_name}
)} {transaction.customer_email && (
{transaction.customer_email}
)}
{/* Amount Breakdown */}

{t('payments.amountBreakdown')}

{t('payments.grossAmount')} {transaction.amount_display}
{t('payments.platformFee')} -{transaction.fee_display}
{transaction.total_refunded > 0 && (
{t('payments.refunded')} -${(transaction.total_refunded / 100).toFixed(2)}
)}
{t('payments.netAmount')} ${(transaction.net_amount / 100).toFixed(2)}
{/* Payment Method */} {transaction.payment_method_info && (

{t('payments.paymentMethod')}

{getPaymentMethodDisplay()}
)} {/* Description */} {transaction.description && (

{t('payments.description')}

{transaction.description}

)} {/* Refund History */} {transaction.refunds && transaction.refunds.length > 0 && (

{t('payments.refundHistory')}

{transaction.refunds.map((refund: RefundInfo) => (

{refund.amount_display}

{refund.reason ? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : t('payments.noReasonProvided')}

{formatRefundDate(refund.created)}

{refund.status === 'succeeded' ? ( ) : ( )} {refund.status}

{refund.id}

))}
)} {/* Timeline */}

{t('payments.timeline')}

{t('payments.created')} {formatDate(transaction.created_at)}
{transaction.updated_at !== transaction.created_at && (
{t('payments.lastUpdated')} {formatDate(transaction.updated_at)}
)}
{/* Technical Details */}

{t('payments.technicalDetails')}

{t('payments.paymentIntent')} {transaction.stripe_payment_intent_id}
{transaction.stripe_charge_id && (
{t('payments.chargeId')} {transaction.stripe_charge_id}
)}
{t('payments.transactionId')} {transaction.id}
{t('payments.currency')} {transaction.currency}
)}
); }; export default TransactionDetailModal;