billing adn account

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-05 00:11:06 +00:00
parent 3a7ea1f4f3
commit 6b291671bd
6 changed files with 1373 additions and 18 deletions

View File

@@ -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 />

View File

@@ -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" },
],
},

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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/');
}