billing adn account
This commit is contained in:
@@ -60,9 +60,12 @@ const Credits = lazy(() => import("./pages/Billing/Credits"));
|
||||
const Transactions = lazy(() => import("./pages/Billing/Transactions"));
|
||||
const Usage = lazy(() => import("./pages/Billing/Usage"));
|
||||
const CreditsAndBilling = lazy(() => import("./pages/Settings/CreditsAndBilling"));
|
||||
const PurchaseCreditsPage = lazy(() => import("./pages/account/PurchaseCreditsPage"));
|
||||
const AccountBillingPage = lazy(() => import("./pages/account/AccountBillingPage"));
|
||||
|
||||
// Admin Module - Lazy loaded
|
||||
const AdminBilling = lazy(() => import("./pages/Admin/AdminBilling"));
|
||||
const PaymentApprovalPage = lazy(() => import("./pages/admin/PaymentApprovalPage"));
|
||||
|
||||
// Reference Data - Lazy loaded
|
||||
const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
|
||||
@@ -352,14 +355,27 @@ export default function App() {
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Admin Routes */}
|
||||
{/* Account Section - New Billing Pages */}
|
||||
<Route path="/account/billing" element={
|
||||
<Suspense fallback={null}>
|
||||
<AccountBillingPage />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/account/credits/purchase" element={
|
||||
<Suspense fallback={null}>
|
||||
<PurchaseCreditsPage />
|
||||
</Suspense>
|
||||
} /> {/* Admin Routes */}
|
||||
<Route path="/admin/billing" element={
|
||||
<Suspense fallback={null}>
|
||||
<AdminBilling />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Reference Data */}
|
||||
<Route path="/admin/payments/approvals" element={
|
||||
<Suspense fallback={null}>
|
||||
<PaymentApprovalPage />
|
||||
</Suspense>
|
||||
} /> {/* Reference Data */}
|
||||
<Route path="/reference/seed-keywords" element={
|
||||
<Suspense fallback={null}>
|
||||
<SeedKeywords />
|
||||
|
||||
@@ -172,6 +172,21 @@ const AppSidebar: React.FC = () => {
|
||||
label: "WORKFLOW",
|
||||
items: workflowItems,
|
||||
},
|
||||
{
|
||||
label: "ACCOUNT",
|
||||
items: [
|
||||
{
|
||||
icon: <DollarLineIcon />,
|
||||
name: "Plans & Billing",
|
||||
path: "/account/billing",
|
||||
},
|
||||
{
|
||||
icon: <DollarLineIcon />,
|
||||
name: "Purchase Credits",
|
||||
path: "/account/credits/purchase",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "SETTINGS",
|
||||
items: [
|
||||
@@ -186,16 +201,6 @@ const AppSidebar: React.FC = () => {
|
||||
{ name: "Import / Export", path: "/settings/import-export" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <DollarLineIcon />,
|
||||
name: "Billing",
|
||||
subItems: [
|
||||
{ name: "Overview", path: "/billing/overview" },
|
||||
{ name: "Credits", path: "/billing/credits" },
|
||||
{ name: "Transactions", path: "/billing/transactions" },
|
||||
{ name: "Usage", path: "/billing/usage" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <DocsIcon />,
|
||||
name: "Help & Documentation",
|
||||
@@ -215,6 +220,7 @@ const AppSidebar: React.FC = () => {
|
||||
name: "Billing & Credits",
|
||||
subItems: [
|
||||
{ name: "Billing Management", path: "/admin/billing" },
|
||||
{ name: "Payment Approvals", path: "/admin/payments/approvals" },
|
||||
{ name: "Credit Costs", path: "/admin/credit-costs" },
|
||||
],
|
||||
},
|
||||
|
||||
350
frontend/src/pages/account/AccountBillingPage.tsx
Normal file
350
frontend/src/pages/account/AccountBillingPage.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Account Billing Page
|
||||
* Consolidated billing dashboard with invoices, payments, and credit balance
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
CreditCard,
|
||||
Download,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getInvoices,
|
||||
getPayments,
|
||||
getCreditBalance,
|
||||
downloadInvoicePDF,
|
||||
type Invoice,
|
||||
type Payment,
|
||||
type CreditBalance,
|
||||
} from '../../services/billing.api';
|
||||
|
||||
type TabType = 'overview' | 'invoices' | 'payments';
|
||||
|
||||
export default function AccountBillingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [creditBalance, setCreditBalance] = useState<CreditBalance | null>(null);
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [balanceRes, invoicesRes, paymentsRes] = await Promise.all([
|
||||
getCreditBalance(),
|
||||
getInvoices(),
|
||||
getPayments(),
|
||||
]);
|
||||
|
||||
setCreditBalance(balanceRes);
|
||||
setInvoices(invoicesRes.results);
|
||||
setPayments(paymentsRes.results);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load billing data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadInvoice = async (invoiceId: number, invoiceNumber: string) => {
|
||||
try {
|
||||
const blob = await downloadInvoicePDF(invoiceId);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `invoice-${invoiceNumber}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
alert('Failed to download invoice');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, { bg: string; text: string; icon: any }> = {
|
||||
paid: { bg: 'bg-green-100', text: 'text-green-800', icon: CheckCircle },
|
||||
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: Clock },
|
||||
failed: { bg: 'bg-red-100', text: 'text-red-800', icon: XCircle },
|
||||
void: { bg: 'bg-gray-100', text: 'text-gray-800', icon: XCircle },
|
||||
completed: { bg: 'bg-green-100', text: 'text-green-800', icon: CheckCircle },
|
||||
pending_approval: { bg: 'bg-blue-100', text: 'text-blue-800', icon: Clock },
|
||||
};
|
||||
|
||||
const style = styles[status] || styles.pending;
|
||||
const Icon = style.icon;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${style.bg} ${style.text}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{status.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Plans & Billing</h1>
|
||||
<p className="text-gray-600">Manage your subscription, credits, and billing</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/account/credits/purchase"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Purchase Credits
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="flex gap-8">
|
||||
{[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'invoices', label: 'Invoices' },
|
||||
{ id: 'payments', label: 'Payments' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as TabType)}
|
||||
className={`py-3 border-b-2 font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && creditBalance && (
|
||||
<div className="space-y-6">
|
||||
{/* Credit Balance Card */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm opacity-90 mb-1">Current Balance</div>
|
||||
<div className="text-4xl font-bold">
|
||||
{creditBalance.balance.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm opacity-90">credits</div>
|
||||
</div>
|
||||
<CreditCard className="w-16 h-16 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Current Plan</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Plan:</span>
|
||||
<span className="font-semibold">{creditBalance.subscription_plan}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Monthly Credits:</span>
|
||||
<span className="font-semibold">
|
||||
{creditBalance.monthly_credits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span>
|
||||
{getStatusBadge(creditBalance.subscription_status || 'active')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Recent Activity</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-600">Total Invoices:</div>
|
||||
<div className="text-2xl font-bold">{invoices.length}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-600">Paid Invoices:</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{invoices.filter((i) => i.status === 'paid').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-600">Pending Payments:</div>
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{payments.filter((p) => p.status === 'pending_approval').length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoices Tab */}
|
||||
{activeTab === 'invoices' && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Invoice
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{invoices.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-2 text-gray-400" />
|
||||
No invoices yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium">{invoice.invoice_number}</div>
|
||||
{invoice.line_items[0] && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{invoice.line_items[0].description}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{new Date(invoice.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 font-semibold">
|
||||
${invoice.total_amount}
|
||||
</td>
|
||||
<td className="px-6 py-4">{getStatusBadge(invoice.status)}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleDownloadInvoice(invoice.id, invoice.invoice_number)
|
||||
}
|
||||
className="text-blue-600 hover:text-blue-700 flex items-center gap-1 ml-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payments Tab */}
|
||||
{activeTab === 'payments' && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Method
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reference
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{payments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
|
||||
<CreditCard className="w-12 h-12 mx-auto mb-2 text-gray-400" />
|
||||
No payments yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
payments.map((payment) => (
|
||||
<tr key={payment.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{new Date(payment.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium capitalize">
|
||||
{payment.payment_method.replace('_', ' ')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-mono text-gray-600">
|
||||
{payment.transaction_reference || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 font-semibold">
|
||||
${payment.amount}
|
||||
</td>
|
||||
<td className="px-6 py-4">{getStatusBadge(payment.status)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
430
frontend/src/pages/account/PurchaseCreditsPage.tsx
Normal file
430
frontend/src/pages/account/PurchaseCreditsPage.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Purchase Credits Page
|
||||
* Displays available credit packages and payment methods
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AlertCircle, Check, CreditCard, Building2, Wallet, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
getCreditPackages,
|
||||
getAvailablePaymentMethods,
|
||||
purchaseCreditPackage,
|
||||
submitManualPayment,
|
||||
type CreditPackage,
|
||||
type PaymentMethod,
|
||||
} from '../../services/billing.api';
|
||||
|
||||
export default function PurchaseCreditsPage() {
|
||||
const [packages, setPackages] = useState<CreditPackage[]>([]);
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||
const [selectedPackage, setSelectedPackage] = useState<CreditPackage | null>(null);
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [showManualPaymentForm, setShowManualPaymentForm] = useState(false);
|
||||
const [manualPaymentData, setManualPaymentData] = useState({
|
||||
transaction_reference: '',
|
||||
notes: '',
|
||||
});
|
||||
const [invoiceData, setInvoiceData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [packagesRes, methodsRes] = await Promise.all([
|
||||
getCreditPackages(),
|
||||
getAvailablePaymentMethods(),
|
||||
]);
|
||||
|
||||
setPackages(packagesRes.results);
|
||||
setPaymentMethods(methodsRes.methods);
|
||||
|
||||
// Auto-select first payment method
|
||||
if (methodsRes.methods.length > 0) {
|
||||
setSelectedPaymentMethod(methodsRes.methods[0].type);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load credit packages');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!selectedPackage || !selectedPaymentMethod) {
|
||||
setError('Please select a package and payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPurchasing(true);
|
||||
setError('');
|
||||
|
||||
const response = await purchaseCreditPackage(
|
||||
selectedPackage.id,
|
||||
selectedPaymentMethod
|
||||
);
|
||||
|
||||
if (selectedPaymentMethod === 'stripe') {
|
||||
// Redirect to Stripe checkout
|
||||
setError('Stripe integration pending - coming soon!');
|
||||
} else if (selectedPaymentMethod === 'paypal') {
|
||||
// Redirect to PayPal
|
||||
setError('PayPal integration pending - coming soon!');
|
||||
} else {
|
||||
// Manual payment - show form
|
||||
setInvoiceData(response);
|
||||
setShowManualPaymentForm(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to initiate purchase');
|
||||
} finally {
|
||||
setPurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualPaymentSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!invoiceData || !manualPaymentData.transaction_reference) {
|
||||
setError('Please provide transaction reference');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPurchasing(true);
|
||||
setError('');
|
||||
|
||||
await submitManualPayment({
|
||||
invoice_id: invoiceData.invoice_id,
|
||||
payment_method: selectedPaymentMethod as 'bank_transfer' | 'local_wallet',
|
||||
transaction_reference: manualPaymentData.transaction_reference,
|
||||
notes: manualPaymentData.notes,
|
||||
});
|
||||
|
||||
// Success
|
||||
alert('Payment submitted successfully! Your payment will be reviewed within 1-2 business days.');
|
||||
setShowManualPaymentForm(false);
|
||||
setSelectedPackage(null);
|
||||
setManualPaymentData({ transaction_reference: '', notes: '' });
|
||||
setInvoiceData(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to submit payment');
|
||||
} finally {
|
||||
setPurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentMethodIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'stripe':
|
||||
return <CreditCard className="w-5 h-5" />;
|
||||
case 'bank_transfer':
|
||||
return <Building2 className="w-5 h-5" />;
|
||||
case 'local_wallet':
|
||||
return <Wallet className="w-5 h-5" />;
|
||||
default:
|
||||
return <CreditCard className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showManualPaymentForm && invoiceData) {
|
||||
const selectedMethod = paymentMethods.find((m) => m.type === selectedPaymentMethod);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<h1 className="text-3xl font-bold mb-6">Complete Payment</h1>
|
||||
|
||||
{/* Invoice Details */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Invoice Details</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Invoice Number:</span>
|
||||
<span className="font-mono">{invoiceData.invoice_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Package:</span>
|
||||
<span>{selectedPackage?.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Credits:</span>
|
||||
<span>{selectedPackage?.credits.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold border-t pt-2 mt-2">
|
||||
<span>Total Amount:</span>
|
||||
<span>${invoiceData.total_amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Instructions */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="font-semibold mb-3 text-blue-900">Payment Instructions</h3>
|
||||
<p className="text-blue-800 mb-4">{selectedMethod?.instructions}</p>
|
||||
|
||||
{selectedMethod?.bank_details && (
|
||||
<div className="bg-white rounded p-4 space-y-2">
|
||||
<h4 className="font-semibold mb-2">Bank Account Details:</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-gray-600">Bank Name:</div>
|
||||
<div className="font-mono">{selectedMethod.bank_details.bank_name}</div>
|
||||
<div className="text-gray-600">Account Number:</div>
|
||||
<div className="font-mono">{selectedMethod.bank_details.account_number}</div>
|
||||
<div className="text-gray-600">Routing Number:</div>
|
||||
<div className="font-mono">{selectedMethod.bank_details.routing_number}</div>
|
||||
{selectedMethod.bank_details.swift_code && (
|
||||
<>
|
||||
<div className="text-gray-600">SWIFT Code:</div>
|
||||
<div className="font-mono">{selectedMethod.bank_details.swift_code}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedMethod?.wallet_details && (
|
||||
<div className="bg-white rounded p-4 space-y-2">
|
||||
<h4 className="font-semibold mb-2">Wallet Details:</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-gray-600">Wallet Type:</div>
|
||||
<div className="font-mono">{selectedMethod.wallet_details.wallet_type}</div>
|
||||
<div className="text-gray-600">Wallet ID:</div>
|
||||
<div className="font-mono">{selectedMethod.wallet_details.wallet_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Payment Form */}
|
||||
<form onSubmit={handleManualPaymentSubmit} className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Submit Payment Proof</h3>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Transaction Reference / ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={manualPaymentData.transaction_reference}
|
||||
onChange={(e) =>
|
||||
setManualPaymentData({ ...manualPaymentData, transaction_reference: e.target.value })
|
||||
}
|
||||
placeholder="Enter transaction ID or reference number"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Additional Notes (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={manualPaymentData.notes}
|
||||
onChange={(e) =>
|
||||
setManualPaymentData({ ...manualPaymentData, notes: e.target.value })
|
||||
}
|
||||
placeholder="Any additional information..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowManualPaymentForm(false);
|
||||
setInvoiceData(null);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
disabled={purchasing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={purchasing}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{purchasing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Payment'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2">Purchase Credits</h1>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Choose a credit package and payment method to top up your account balance.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credit Packages */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Select Credit Package</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{packages.map((pkg) => (
|
||||
<div
|
||||
key={pkg.id}
|
||||
onClick={() => setSelectedPackage(pkg)}
|
||||
className={`relative cursor-pointer rounded-lg border-2 p-6 transition-all ${
|
||||
selectedPackage?.id === pkg.id
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-blue-300 bg-white'
|
||||
} ${pkg.is_featured ? 'ring-2 ring-yellow-400' : ''}`}
|
||||
>
|
||||
{pkg.is_featured && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-yellow-400 text-yellow-900 text-xs font-bold px-3 py-1 rounded-full">
|
||||
FEATURED
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-bold mb-2">{pkg.name}</h3>
|
||||
<div className="text-3xl font-bold text-blue-600 mb-1">
|
||||
{pkg.credits.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mb-3">credits</div>
|
||||
<div className="text-2xl font-bold mb-1">${pkg.price}</div>
|
||||
{pkg.discount_percentage > 0 && (
|
||||
<div className="text-sm text-green-600 font-semibold">
|
||||
Save {pkg.discount_percentage}%
|
||||
</div>
|
||||
)}
|
||||
{pkg.description && (
|
||||
<p className="text-sm text-gray-600 mt-3">{pkg.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPackage?.id === pkg.id && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Methods */}
|
||||
{selectedPackage && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Select Payment Method</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paymentMethods.map((method) => (
|
||||
<div
|
||||
key={method.type}
|
||||
onClick={() => setSelectedPaymentMethod(method.type)}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedPaymentMethod === method.type
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-blue-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
selectedPaymentMethod === method.type
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{getPaymentMethodIcon(method.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">{method.name}</h3>
|
||||
<p className="text-sm text-gray-600">{method.instructions}</p>
|
||||
</div>
|
||||
{selectedPaymentMethod === method.type && (
|
||||
<Check className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchase Button */}
|
||||
{selectedPackage && selectedPaymentMethod && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600">You're purchasing:</div>
|
||||
<div className="text-lg font-bold">{selectedPackage.name}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{selectedPackage.credits.toLocaleString()} credits
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">Total:</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
${selectedPackage.price}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePurchase}
|
||||
disabled={purchasing}
|
||||
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-semibold text-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
{purchasing ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Proceed to Payment'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
frontend/src/pages/admin/PaymentApprovalPage.tsx
Normal file
300
frontend/src/pages/admin/PaymentApprovalPage.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Admin Payment Approval Page
|
||||
* For approving/rejecting manual payments (bank transfers, wallet payments)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Check, X, AlertCircle, Loader2, Building2, Wallet, Clock } from 'lucide-react';
|
||||
import {
|
||||
getPendingPayments,
|
||||
approvePayment,
|
||||
rejectPayment,
|
||||
type PendingPayment,
|
||||
} from '../../services/billing.api';
|
||||
|
||||
export default function AdminPaymentApprovalPage() {
|
||||
const [payments, setPayments] = useState<PendingPayment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processing, setProcessing] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [selectedPayment, setSelectedPayment] = useState<PendingPayment | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [approvalNotes, setApprovalNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadPayments();
|
||||
}, []);
|
||||
|
||||
const loadPayments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getPendingPayments();
|
||||
setPayments(response.results);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load pending payments');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (paymentId: number) => {
|
||||
if (!confirm('Are you sure you want to approve this payment?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(paymentId);
|
||||
setError('');
|
||||
|
||||
await approvePayment(paymentId, approvalNotes || undefined);
|
||||
|
||||
// Remove from list
|
||||
setPayments(payments.filter((p) => p.id !== paymentId));
|
||||
setApprovalNotes('');
|
||||
alert('Payment approved successfully!');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to approve payment');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!selectedPayment || !rejectReason.trim()) {
|
||||
setError('Please provide a rejection reason');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(selectedPayment.id);
|
||||
setError('');
|
||||
|
||||
await rejectPayment(selectedPayment.id, rejectReason);
|
||||
|
||||
// Remove from list
|
||||
setPayments(payments.filter((p) => p.id !== selectedPayment.id));
|
||||
setShowRejectModal(false);
|
||||
setSelectedPayment(null);
|
||||
setRejectReason('');
|
||||
alert('Payment rejected successfully!');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to reject payment');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentMethodIcon = (method: string) => {
|
||||
if (method.includes('bank')) return <Building2 className="w-5 h-5" />;
|
||||
if (method.includes('wallet')) return <Wallet className="w-5 h-5" />;
|
||||
return <Clock className="w-5 h-5" />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Payment Approvals</h1>
|
||||
<p className="text-gray-600">Review and approve manual payment submissions</p>
|
||||
</div>
|
||||
<div className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-lg font-semibold">
|
||||
{payments.length} Pending
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payments.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||
<Clock className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">No Pending Payments</h3>
|
||||
<p className="text-gray-500">All payments have been reviewed</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{payments.map((payment) => (
|
||||
<div key={payment.id} className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div className="p-3 bg-yellow-100 rounded-lg text-yellow-600">
|
||||
{getPaymentMethodIcon(payment.payment_method)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{payment.account_name}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Payment Method: {payment.payment_method.replace('_', ' ').toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
${payment.amount} {payment.currency}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{new Date(payment.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 bg-gray-50 rounded p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 mb-1">
|
||||
Transaction Reference
|
||||
</div>
|
||||
<div className="font-mono text-sm bg-white px-2 py-1 rounded border">
|
||||
{payment.transaction_reference}
|
||||
</div>
|
||||
</div>
|
||||
{payment.invoice_number && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 mb-1">
|
||||
Invoice Number
|
||||
</div>
|
||||
<div className="font-mono text-sm bg-white px-2 py-1 rounded border">
|
||||
{payment.invoice_number}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{payment.admin_notes && (
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-sm font-medium text-gray-700 mb-1">
|
||||
User Notes
|
||||
</div>
|
||||
<div className="text-sm bg-white px-3 py-2 rounded border">
|
||||
{payment.admin_notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approval Notes Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Approval Notes (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={approvalNotes}
|
||||
onChange={(e) => setApprovalNotes(e.target.value)}
|
||||
placeholder="Add any notes about this approval..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleApprove(payment.id)}
|
||||
disabled={processing === payment.id}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium"
|
||||
>
|
||||
{processing === payment.id ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Approving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Approve Payment
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPayment(payment);
|
||||
setShowRejectModal(true);
|
||||
}}
|
||||
disabled={processing === payment.id}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Reject Payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Modal */}
|
||||
{showRejectModal && selectedPayment && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Reject Payment</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Please provide a reason for rejecting this payment. The user will be notified.
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Rejection Reason *
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="e.g., Transaction reference not found, incorrect amount, invalid payment proof..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRejectModal(false);
|
||||
setSelectedPayment(null);
|
||||
setRejectReason('');
|
||||
}}
|
||||
disabled={processing !== null}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={processing !== null || !rejectReason.trim()}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing !== null ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Rejecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-4 h-4" />
|
||||
Reject
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
frontend/src/services/billing.api.ts
Normal file
253
frontend/src/services/billing.api.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Billing API Service
|
||||
* Handles all billing-related API calls
|
||||
*/
|
||||
|
||||
import { fetchAPI } from './api';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface CreditPackage {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
credits: number;
|
||||
price: string;
|
||||
discount_percentage: number;
|
||||
is_featured: boolean;
|
||||
description: string;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
status: 'draft' | 'pending' | 'paid' | 'void';
|
||||
total_amount: string;
|
||||
subtotal: string;
|
||||
tax_amount: string;
|
||||
currency: string;
|
||||
created_at: string;
|
||||
paid_at: string | null;
|
||||
due_date: string | null;
|
||||
line_items: Array<{
|
||||
description: string;
|
||||
amount: string;
|
||||
quantity: number;
|
||||
}>;
|
||||
billing_period_start: string | null;
|
||||
billing_period_end: string | null;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: number;
|
||||
amount: string;
|
||||
currency: string;
|
||||
payment_method: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet' | 'manual';
|
||||
status: 'pending' | 'completed' | 'failed' | 'pending_approval';
|
||||
created_at: string;
|
||||
processed_at: string | null;
|
||||
invoice_id: number;
|
||||
invoice_number: string | null;
|
||||
transaction_reference: string;
|
||||
failure_reason: string | null;
|
||||
}
|
||||
|
||||
export interface PaymentMethod {
|
||||
type: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
bank_details?: {
|
||||
bank_name: string;
|
||||
account_number: string;
|
||||
routing_number: string;
|
||||
swift_code: string;
|
||||
};
|
||||
wallet_details?: {
|
||||
wallet_type: string;
|
||||
wallet_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreditTransaction {
|
||||
id: number;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
reference_id: string;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CreditBalance {
|
||||
balance: number;
|
||||
subscription_plan: string;
|
||||
monthly_credits: number;
|
||||
subscription_status: string | null;
|
||||
}
|
||||
|
||||
export interface PendingPayment {
|
||||
id: number;
|
||||
account_name: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
payment_method: string;
|
||||
transaction_reference: string;
|
||||
created_at: string;
|
||||
invoice_number: string | null;
|
||||
admin_notes: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CREDIT PACKAGES
|
||||
// ============================================================================
|
||||
|
||||
export async function getCreditPackages(): Promise<{ results: CreditPackage[]; count: number }> {
|
||||
return fetchAPI('/billing/v2/credit-packages/');
|
||||
}
|
||||
|
||||
export async function purchaseCreditPackage(
|
||||
packageId: number,
|
||||
paymentMethod: string
|
||||
): Promise<{
|
||||
invoice_id: number;
|
||||
invoice_number: string;
|
||||
total_amount: string;
|
||||
message: string;
|
||||
next_action: string;
|
||||
}> {
|
||||
return fetchAPI(`/billing/v2/credit-packages/${packageId}/purchase/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payment_method: paymentMethod }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INVOICES
|
||||
// ============================================================================
|
||||
|
||||
export async function getInvoices(status?: string): Promise<{ results: Invoice[]; count: number }> {
|
||||
const params = status ? `?status=${status}` : '';
|
||||
return fetchAPI(`/billing/v2/invoices/${params}`);
|
||||
}
|
||||
|
||||
export async function getInvoice(invoiceId: number): Promise<Invoice> {
|
||||
return fetchAPI(`/billing/v2/invoices/${invoiceId}/`);
|
||||
}
|
||||
|
||||
export async function downloadInvoicePDF(invoiceId: number): Promise<Blob> {
|
||||
const response = await fetch(`/api/v1/billing/v2/invoices/${invoiceId}/download_pdf/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download invoice');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAYMENTS
|
||||
// ============================================================================
|
||||
|
||||
export async function getPayments(status?: string): Promise<{ results: Payment[]; count: number }> {
|
||||
const params = status ? `?status=${status}` : '';
|
||||
return fetchAPI(`/billing/v2/payments/${params}`);
|
||||
}
|
||||
|
||||
export async function getAvailablePaymentMethods(): Promise<{
|
||||
methods: PaymentMethod[];
|
||||
stripe: boolean;
|
||||
paypal: boolean;
|
||||
bank_transfer: boolean;
|
||||
local_wallet: boolean;
|
||||
}> {
|
||||
return fetchAPI('/billing/v2/payments/available_methods/');
|
||||
}
|
||||
|
||||
export async function submitManualPayment(data: {
|
||||
invoice_id: number;
|
||||
payment_method: 'bank_transfer' | 'local_wallet';
|
||||
transaction_reference: string;
|
||||
notes?: string;
|
||||
}): Promise<{
|
||||
id: number;
|
||||
status: string;
|
||||
message: string;
|
||||
}> {
|
||||
return fetchAPI('/billing/v2/payments/create_manual_payment/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CREDIT TRANSACTIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function getCreditTransactions(): Promise<{
|
||||
results: CreditTransaction[];
|
||||
count: number;
|
||||
current_balance: number;
|
||||
}> {
|
||||
return fetchAPI('/billing/v2/transactions/');
|
||||
}
|
||||
|
||||
export async function getCreditBalance(): Promise<CreditBalance> {
|
||||
return fetchAPI('/billing/v2/transactions/balance/');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN - PAYMENT APPROVALS
|
||||
// ============================================================================
|
||||
|
||||
export async function getPendingPayments(): Promise<{
|
||||
results: PendingPayment[];
|
||||
count: number;
|
||||
}> {
|
||||
return fetchAPI('/billing/v2/admin/pending_payments/');
|
||||
}
|
||||
|
||||
export async function approvePayment(
|
||||
paymentId: number,
|
||||
notes?: string
|
||||
): Promise<{
|
||||
id: number;
|
||||
status: string;
|
||||
message: string;
|
||||
}> {
|
||||
return fetchAPI(`/billing/v2/admin/${paymentId}/approve_payment/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notes }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectPayment(
|
||||
paymentId: number,
|
||||
reason: string
|
||||
): Promise<{
|
||||
id: number;
|
||||
status: string;
|
||||
message: string;
|
||||
}> {
|
||||
return fetchAPI(`/billing/v2/admin/${paymentId}/reject_payment/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminBillingStats(): Promise<{
|
||||
total_accounts: number;
|
||||
active_subscriptions: number;
|
||||
total_revenue: string;
|
||||
pending_approvals: number;
|
||||
invoices_pending: number;
|
||||
invoices_paid: number;
|
||||
}> {
|
||||
return fetchAPI('/billing/v2/admin/stats/');
|
||||
}
|
||||
Reference in New Issue
Block a user