Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements

This commit is contained in:
ajaysi
2026-02-28 20:06:26 +05:30
parent 08a1f4a1d8
commit 4828274cbf
162 changed files with 19489 additions and 4300 deletions

View File

@@ -14,13 +14,15 @@ import {
import {
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { DollarSign } from 'lucide-react';
import { DollarSign, CreditCard } from 'lucide-react';
import { styled } from '@mui/material/styles';
import EnhancedBillingDashboard from '../components/billing/EnhancedBillingDashboard';
import UsageLogsTable from '../components/billing/UsageLogsTable';
import SubscriptionRenewalHistory from '../components/billing/SubscriptionRenewalHistory';
import { showToastNotification } from '../utils/toastNotifications';
import { useSubscription } from '../contexts/SubscriptionContext';
import { apiClient } from '../api/client';
import { Button } from '@mui/material';
// Terminal-themed styled components
const TerminalContainer = styled(Container)(({ theme }) => ({
@@ -164,6 +166,21 @@ const BillingPage: React.FC = () => {
}, 500);
};
const handleManageBilling = async () => {
try {
showToastNotification('Opening billing portal...', 'info');
const response = await apiClient.post('/api/subscription/create-portal-session', {
return_url: window.location.href
});
if (response.data.url) {
window.location.href = response.data.url;
}
} catch (error) {
console.error('Failed to create portal session', error);
showToastNotification('Failed to access billing portal', 'error');
}
};
return (
<TerminalContainer maxWidth="xl">
{/* Header */}
@@ -183,6 +200,21 @@ const BillingPage: React.FC = () => {
</Box>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<Button
variant="outlined"
startIcon={<CreditCard size={18} />}
onClick={handleManageBilling}
sx={{
borderColor: '#00ff00',
color: '#00ff00',
'&:hover': {
borderColor: '#00cc00',
backgroundColor: 'rgba(0, 255, 0, 0.1)'
}
}}
>
Manage Billing
</Button>
<Tooltip
title="Refresh billing data"
arrow

View File

@@ -0,0 +1,696 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Button, CircularProgress, Alert, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, Tabs, Tab } from '@mui/material';
import { apiClient } from '../api/client';
interface Dispute {
id: string;
amount: number;
currency: string;
status: string;
reason: string | null;
charge?: string | null;
created: number;
is_charge_refundable?: boolean;
evidence?: {
customer_email_address?: string | null;
customer_name?: string | null;
customer_purchase_ip?: string | null;
access_activity_log?: string | null;
uncategorized_text?: string | null;
} | null;
}
interface DisputeListResponse {
data: {
object: string;
url: string;
has_more: boolean;
data: Dispute[];
};
}
interface DisputeResponse {
data: Dispute;
}
interface FraudWarning {
id: string;
charge_id: string;
payment_intent_id: string | null;
user_id: string | null;
amount: number;
currency: string;
status: string;
action: string;
action_at: string | null;
reason_notes?: string | null;
created_at: string | null;
meta_info?: any;
}
interface FraudWarningListResponse {
data: FraudWarning[];
}
interface FraudWarningResponse {
data: FraudWarning;
}
const StripeDisputesDashboard: React.FC = () => {
const [disputes, setDisputes] = useState<Dispute[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
const [closing, setClosing] = useState(false);
const [evidenceEmail, setEvidenceEmail] = useState('');
const [evidenceName, setEvidenceName] = useState('');
const [evidenceIp, setEvidenceIp] = useState('');
const [evidenceLog, setEvidenceLog] = useState('');
const [evidenceNotes, setEvidenceNotes] = useState('');
const [fraudType, setFraudType] = useState('');
const [submittingEvidence, setSubmittingEvidence] = useState(false);
const [tab, setTab] = useState<'disputes' | 'fraudWarnings'>('disputes');
const [fraudWarnings, setFraudWarnings] = useState<FraudWarning[]>([]);
const [fraudLoading, setFraudLoading] = useState(false);
const [selectedWarning, setSelectedWarning] = useState<FraudWarning | null>(null);
const [refundProcessing, setRefundProcessing] = useState(false);
const [ignoreProcessing, setIgnoreProcessing] = useState(false);
const [actionNotes, setActionNotes] = useState('');
const loadDisputes = async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get<DisputeListResponse>('/api/subscription/disputes', {
params: { limit: 20 },
});
const list = response.data?.data;
setDisputes(Array.isArray(list?.data) ? list.data : []);
} catch (e: any) {
const message = e?.message || 'Failed to load disputes';
setError(message);
} finally {
setLoading(false);
}
};
const loadDisputeDetails = async (id: string) => {
setError(null);
try {
const response = await apiClient.get<DisputeResponse>(`/api/subscription/disputes/${id}`);
if (response.data?.data) {
setSelectedDispute(response.data.data);
}
} catch (e: any) {
const message = e?.message || 'Failed to load dispute details';
setError(message);
}
};
const handleViewDetails = (id: string) => {
loadDisputeDetails(id);
};
const handleCloseDispute = async () => {
if (!selectedDispute) return;
setClosing(true);
setError(null);
try {
await apiClient.post(`/api/subscription/disputes/${selectedDispute.id}/close`);
await loadDisputes();
setSelectedDispute(null);
} catch (e: any) {
const message = e?.message || 'Failed to close dispute';
setError(message);
} finally {
setClosing(false);
}
};
const handleSubmitEvidence = async () => {
if (!selectedDispute) return;
setSubmittingEvidence(true);
setError(null);
try {
const evidence: any = {};
if (evidenceEmail.trim()) {
evidence.customer_email_address = evidenceEmail.trim();
}
if (evidenceName.trim()) {
evidence.customer_name = evidenceName.trim();
}
if (evidenceIp.trim()) {
evidence.customer_purchase_ip = evidenceIp.trim();
}
if (evidenceLog.trim()) {
evidence.access_activity_log = evidenceLog.trim();
}
if (evidenceNotes.trim() || fraudType) {
const prefix = fraudType ? `[fraud_type=${fraudType}] ` : '';
evidence.uncategorized_text = prefix + evidenceNotes.trim();
}
if (Object.keys(evidence).length === 0) {
setError('Please provide at least one evidence field before submitting.');
setSubmittingEvidence(false);
return;
}
await apiClient.post(`/api/subscription/disputes/${selectedDispute.id}`, { evidence });
await loadDisputeDetails(selectedDispute.id);
} catch (e: any) {
const message = e?.message || 'Failed to submit evidence';
setError(message);
} finally {
setSubmittingEvidence(false);
}
};
const loadFraudWarnings = async () => {
setFraudLoading(true);
setError(null);
try {
const response = await apiClient.get<FraudWarningListResponse>('/api/subscription/fraud-warnings', {
params: { status: 'open', limit: 20 },
});
const list = response.data?.data;
setFraudWarnings(Array.isArray(list) ? list : []);
} catch (e: any) {
const message = e?.message || 'Failed to load fraud warnings';
setError(message);
} finally {
setFraudLoading(false);
}
};
const loadFraudWarningDetails = async (id: string) => {
setError(null);
try {
const response = await apiClient.get<FraudWarningResponse>(`/api/subscription/fraud-warnings/${id}`);
if (response.data?.data) {
setSelectedWarning(response.data.data);
}
} catch (e: any) {
const message = e?.message || 'Failed to load fraud warning details';
setError(message);
}
};
const handleViewWarning = (id: string) => {
loadFraudWarningDetails(id);
};
const handleRefundWarning = async () => {
if (!selectedWarning) return;
setRefundProcessing(true);
setError(null);
try {
const body: any = {};
if (actionNotes.trim()) {
body.notes = actionNotes.trim();
}
await apiClient.post(`/api/subscription/fraud-warnings/${selectedWarning.id}/refund`, body);
await loadFraudWarnings();
await loadFraudWarningDetails(selectedWarning.id);
} catch (e: any) {
const message = e?.message || 'Failed to refund charge';
setError(message);
} finally {
setRefundProcessing(false);
}
};
const handleIgnoreWarning = async () => {
if (!selectedWarning) return;
setIgnoreProcessing(true);
setError(null);
try {
const body: any = {};
if (actionNotes.trim()) {
body.notes = actionNotes.trim();
}
await apiClient.post(`/api/subscription/fraud-warnings/${selectedWarning.id}/ignore`, body);
await loadFraudWarnings();
await loadFraudWarningDetails(selectedWarning.id);
} catch (e: any) {
const message = e?.message || 'Failed to update fraud warning';
setError(message);
} finally {
setIgnoreProcessing(false);
}
};
useEffect(() => {
loadDisputes();
}, []);
useEffect(() => {
if (selectedDispute && selectedDispute.evidence) {
const ev = selectedDispute.evidence;
setEvidenceEmail(ev.customer_email_address || '');
setEvidenceName(ev.customer_name || '');
setEvidenceIp(ev.customer_purchase_ip || '');
setEvidenceLog(ev.access_activity_log || '');
if (ev.uncategorized_text) {
setEvidenceNotes(ev.uncategorized_text);
} else {
setEvidenceNotes('');
}
} else {
setEvidenceEmail('');
setEvidenceName('');
setEvidenceIp('');
setEvidenceLog('');
setEvidenceNotes('');
setFraudType('');
}
}, [selectedDispute]);
useEffect(() => {
if (tab === 'fraudWarnings') {
loadFraudWarnings();
}
}, [tab]);
useEffect(() => {
if (selectedWarning && selectedWarning.reason_notes) {
setActionNotes(selectedWarning.reason_notes);
} else {
setActionNotes('');
}
}, [selectedWarning]);
const formatAmount = (amount: number, currency: string) => {
const value = amount / 100;
const code = (currency || 'usd').toUpperCase();
return `${value.toFixed(2)} ${code}`;
};
const formatDate = (unix: number) => {
if (!unix) return '-';
return new Date(unix * 1000).toLocaleString();
};
const formatIsoDate = (iso: string | null) => {
if (!iso) return '-';
return new Date(iso).toLocaleString();
};
return (
<Box p={3}>
<Typography variant="h4" gutterBottom>
Stripe Disputes
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
Internal dashboard for viewing and managing Stripe disputes. This view is intended for admins only.
</Typography>
<Box mt={2}>
<Alert severity="info">
Use this page to review Stripe disputes and submit clear evidence. For
background on fraud patterns, see{' '}
<a
href="https://docs.stripe.com/disputes/prevention/fraud-types"
target="_blank"
rel="noreferrer"
>
Common types of online fraud
</a>
,{' '}
<a
href="https://docs.stripe.com/disputes/prevention/card-testing"
target="_blank"
rel="noreferrer"
>
Card testing
</a>
, and{' '}
<a
href="https://docs.stripe.com/disputes/prevention/identifying-fraud"
target="_blank"
rel="noreferrer"
>
Identifying potential fraud
</a>
.
</Alert>
</Box>
{error && (
<Box mt={2}>
<Alert severity="error">{error}</Alert>
</Box>
)}
<Box mt={2}>
<Tabs
value={tab}
onChange={(_, value) => setTab(value)}
>
<Tab label="Disputes" value="disputes" />
<Tab label="Fraud Warnings" value="fraudWarnings" />
</Tabs>
</Box>
<Box mt={2} mb={2} display="flex" justifyContent="space-between" alignItems="center">
{tab === 'disputes' ? (
<Button variant="outlined" onClick={loadDisputes} disabled={loading}>
Refresh disputes
</Button>
) : (
<Button variant="outlined" onClick={loadFraudWarnings} disabled={fraudLoading}>
Refresh warnings
</Button>
)}
</Box>
{tab === 'disputes' ? (
loading ? (
<Box display="flex" justifyContent="center" alignItems="center" mt={4}>
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Status</TableCell>
<TableCell>Reason</TableCell>
<TableCell>Charge</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{disputes.length === 0 && (
<TableRow>
<TableCell colSpan={7} align="center">
No disputes found.
</TableCell>
</TableRow>
)}
{disputes.map((d) => (
<TableRow key={d.id}>
<TableCell>{d.id}</TableCell>
<TableCell>{formatAmount(d.amount, d.currency)}</TableCell>
<TableCell>{d.status}</TableCell>
<TableCell>{d.reason || '-'}</TableCell>
<TableCell>{d.charge || '-'}</TableCell>
<TableCell>{formatDate(d.created)}</TableCell>
<TableCell align="right">
<Button
size="small"
variant="text"
onClick={() => handleViewDetails(d.id)}
sx={{ mr: 1 }}
>
Details
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handleViewDetails(d.id)}
>
Close
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
) : fraudLoading ? (
<Box display="flex" justifyContent="center" alignItems="center" mt={4}>
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Charge</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Status</TableCell>
<TableCell>Action</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{fraudWarnings.length === 0 && (
<TableRow>
<TableCell colSpan={7} align="center">
No fraud warnings found.
</TableCell>
</TableRow>
)}
{fraudWarnings.map((fw) => (
<TableRow key={fw.id}>
<TableCell>{fw.id}</TableCell>
<TableCell>{fw.charge_id}</TableCell>
<TableCell>{formatAmount(fw.amount, fw.currency)}</TableCell>
<TableCell>{fw.status}</TableCell>
<TableCell>{fw.action}</TableCell>
<TableCell>{formatIsoDate(fw.created_at)}</TableCell>
<TableCell align="right">
<Button
size="small"
variant="text"
onClick={() => handleViewWarning(fw.id)}
>
Details
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Dialog
open={!!selectedDispute}
onClose={() => setSelectedDispute(null)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Dispute Details</DialogTitle>
<DialogContent dividers>
{selectedDispute && (
<Box>
<Typography variant="subtitle2">ID</Typography>
<Typography variant="body2" gutterBottom>{selectedDispute.id}</Typography>
<Typography variant="subtitle2">Amount</Typography>
<Typography variant="body2" gutterBottom>
{formatAmount(selectedDispute.amount, selectedDispute.currency)}
</Typography>
<Typography variant="subtitle2">Status</Typography>
<Typography variant="body2" gutterBottom>{selectedDispute.status}</Typography>
<Typography variant="subtitle2">Reason</Typography>
<Typography variant="body2" gutterBottom>{selectedDispute.reason || '-'}</Typography>
<Typography variant="subtitle2">Charge</Typography>
<Typography variant="body2" gutterBottom>{selectedDispute.charge || '-'}</Typography>
<Typography variant="subtitle2">Created</Typography>
<Typography variant="body2" gutterBottom>{formatDate(selectedDispute.created)}</Typography>
<Box mt={2}>
<Typography variant="subtitle2">Fraud Type</Typography>
<TextField
select
fullWidth
size="small"
margin="dense"
value={fraudType}
onChange={(e) => setFraudType(e.target.value)}
helperText="Choose the main fraud pattern: card testing, stolen card, overpayment, alternative refund, or other."
>
<MenuItem value="">Not specified</MenuItem>
<MenuItem value="card_testing">Card testing</MenuItem>
<MenuItem value="stolen_card">Stolen card</MenuItem>
<MenuItem value="overpayment_fraud">Overpayment fraud</MenuItem>
<MenuItem value="alternative_refund">Alternative refund</MenuItem>
<MenuItem value="other">Other</MenuItem>
</TextField>
</Box>
<Box mt={1}>
<Typography variant="subtitle2">Customer Email</Typography>
<TextField
fullWidth
size="small"
margin="dense"
value={evidenceEmail}
onChange={(e) => setEvidenceEmail(e.target.value)}
/>
</Box>
<Box mt={1}>
<Typography variant="subtitle2">Customer Name</Typography>
<TextField
fullWidth
size="small"
margin="dense"
value={evidenceName}
onChange={(e) => setEvidenceName(e.target.value)}
/>
</Box>
<Box mt={1}>
<Typography variant="subtitle2">Customer IP</Typography>
<TextField
fullWidth
size="small"
margin="dense"
value={evidenceIp}
onChange={(e) => setEvidenceIp(e.target.value)}
/>
</Box>
<Box mt={1}>
<Typography variant="subtitle2">Access Activity Log</Typography>
<TextField
fullWidth
multiline
minRows={3}
margin="dense"
value={evidenceLog}
onChange={(e) => setEvidenceLog(e.target.value)}
/>
</Box>
<Box mt={1}>
<Typography variant="subtitle2">Fraud Indicators / Notes</Typography>
<TextField
fullWidth
multiline
minRows={3}
margin="dense"
value={evidenceNotes}
onChange={(e) => setEvidenceNotes(e.target.value)}
placeholder="Describe what looks suspicious: many failed attempts, overpayment + refund request, mismatched details, etc."
helperText="Summaries here should match patterns described in Stripe docs: card testing spikes, stolen card indicators, overpayment/alternative refund scams."
/>
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setSelectedDispute(null)} disabled={closing}>
Cancel
</Button>
<Button
onClick={handleSubmitEvidence}
color="primary"
variant="contained"
disabled={submittingEvidence || closing}
>
{submittingEvidence ? 'Submitting...' : 'Submit Evidence'}
</Button>
<Button
onClick={handleCloseDispute}
color="error"
variant="contained"
disabled={closing}
>
{closing ? 'Closing...' : 'Close Dispute'}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={!!selectedWarning}
onClose={() => setSelectedWarning(null)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Fraud Warning Details</DialogTitle>
<DialogContent dividers>
{selectedWarning && (
<Box>
<Typography variant="subtitle2">ID</Typography>
<Typography variant="body2" gutterBottom>{selectedWarning.id}</Typography>
<Typography variant="subtitle2">Charge</Typography>
<Typography variant="body2" gutterBottom>{selectedWarning.charge_id}</Typography>
<Typography variant="subtitle2">Amount</Typography>
<Typography variant="body2" gutterBottom>
{formatAmount(selectedWarning.amount, selectedWarning.currency)}
</Typography>
<Typography variant="subtitle2">Status</Typography>
<Typography variant="body2" gutterBottom>{selectedWarning.status}</Typography>
<Typography variant="subtitle2">Action</Typography>
<Typography variant="body2" gutterBottom>{selectedWarning.action}</Typography>
<Typography variant="subtitle2">Created</Typography>
<Typography variant="body2" gutterBottom>{formatIsoDate(selectedWarning.created_at)}</Typography>
<Typography variant="subtitle2">Last Action At</Typography>
<Typography variant="body2" gutterBottom>{formatIsoDate(selectedWarning.action_at)}</Typography>
<Typography variant="subtitle2">Issuer Fraud Type</Typography>
<Typography variant="body2" gutterBottom>
{selectedWarning.meta_info?.early_fraud_warning?.fraud_type || '-'}
</Typography>
<Typography variant="subtitle2">Actionable</Typography>
<Typography variant="body2" gutterBottom>
{selectedWarning.meta_info?.early_fraud_warning?.actionable === true
? 'Yes'
: selectedWarning.meta_info?.early_fraud_warning?.actionable === false
? 'No'
: '-'}
</Typography>
<Box mt={2}>
<Typography variant="subtitle2">Action Notes</Typography>
<TextField
fullWidth
multiline
minRows={2}
margin="dense"
value={actionNotes}
onChange={(e) => setActionNotes(e.target.value)}
/>
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => setSelectedWarning(null)}
disabled={refundProcessing || ignoreProcessing}
>
Close
</Button>
<Button
onClick={handleIgnoreWarning}
disabled={ignoreProcessing || refundProcessing}
>
{ignoreProcessing ? 'Marking...' : 'Mark as Ignored'}
</Button>
<Button
onClick={handleRefundWarning}
color="error"
variant="contained"
disabled={refundProcessing || ignoreProcessing}
>
{refundProcessing ? 'Refunding...' : 'Refund Full Amount'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default StripeDisputesDashboard;