billing and paymetn methods

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-07 04:28:46 +00:00
parent 31c06d032c
commit 4e764e208d
11 changed files with 1010 additions and 349 deletions

View File

@@ -40,8 +40,6 @@ class Igny8AdminSite(admin.AdminSite):
('billing', 'Invoice'),
('billing', 'Payment'),
('billing', 'CreditPackage'),
('billing', 'PaymentMethodConfig'),
('billing', 'AccountPaymentMethod'),
('billing', 'CreditCostConfig'),
],
},
@@ -107,6 +105,12 @@ class Igny8AdminSite(admin.AdminSite):
('automation', 'AutomationRun'),
],
},
'Payments': {
'models': [
('billing', 'PaymentMethodConfig'),
('billing', 'AccountPaymentMethod'),
],
},
'Integrations & Sync': {
'models': [
('integration', 'SiteIntegration'),
@@ -170,6 +174,7 @@ class Igny8AdminSite(admin.AdminSite):
'Writer Module',
'Thinker Module',
'System Configuration',
'Payments',
'Integrations & Sync',
'Publishing',
'Optimization',

View File

@@ -545,6 +545,179 @@ class AdminBillingViewSet(viewsets.ViewSet):
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get', 'post'])
def payment_method_configs(self, request):
"""List/create payment method configs (country-level)"""
error = self._require_admin(request)
if error:
return error
class PMConfigSerializer(serializers.ModelSerializer):
class Meta:
model = PaymentMethodConfig
fields = [
'id',
'country_code',
'payment_method',
'display_name',
'is_enabled',
'instructions',
'sort_order',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
if request.method.lower() == 'post':
serializer = PMConfigSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
return Response(PMConfigSerializer(obj).data, status=status.HTTP_201_CREATED)
qs = PaymentMethodConfig.objects.all().order_by('country_code', 'sort_order', 'payment_method')
country = request.query_params.get('country_code')
method = request.query_params.get('payment_method')
if country:
qs = qs.filter(country_code=country)
if method:
qs = qs.filter(payment_method=method)
data = PMConfigSerializer(qs, many=True).data
return Response({'results': data, 'count': len(data)})
@action(detail=True, methods=['get', 'patch', 'put', 'delete'], url_path='payment_method_config')
@extend_schema(tags=['Admin Billing'])
def payment_method_config(self, request, pk=None):
"""Retrieve/update/delete a payment method config"""
error = self._require_admin(request)
if error:
return error
obj = get_object_or_404(PaymentMethodConfig, id=pk)
class PMConfigSerializer(serializers.ModelSerializer):
class Meta:
model = PaymentMethodConfig
fields = [
'id',
'country_code',
'payment_method',
'display_name',
'is_enabled',
'instructions',
'sort_order',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
if request.method.lower() == 'get':
return Response(PMConfigSerializer(obj).data)
if request.method.lower() in ['patch', 'put']:
partial = request.method.lower() == 'patch'
serializer = PMConfigSerializer(obj, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
return Response(PMConfigSerializer(obj).data)
# delete
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=['get', 'post'])
@extend_schema(tags=['Admin Billing'])
def account_payment_methods(self, request):
"""List/create account payment methods (admin)"""
error = self._require_admin(request)
if error:
return error
class AccountPMSerializer(serializers.ModelSerializer):
class Meta:
model = AccountPaymentMethod
fields = [
'id',
'account',
'type',
'display_name',
'is_default',
'is_enabled',
'is_verified',
'country_code',
'instructions',
'metadata',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'is_verified', 'created_at', 'updated_at']
if request.method.lower() == 'post':
serializer = AccountPMSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
return Response(AccountPMSerializer(obj).data, status=status.HTTP_201_CREATED)
qs = AccountPaymentMethod.objects.select_related('account').order_by('account_id', '-is_default', 'display_name')
account_id = request.query_params.get('account_id')
if account_id:
qs = qs.filter(account_id=account_id)
data = AccountPMSerializer(qs, many=True).data
return Response({'results': data, 'count': len(data)})
@action(detail=True, methods=['get', 'patch', 'put', 'delete'], url_path='account_payment_method')
@extend_schema(tags=['Admin Billing'])
def account_payment_method(self, request, pk=None):
"""Retrieve/update/delete an account payment method (admin)"""
error = self._require_admin(request)
if error:
return error
obj = get_object_or_404(AccountPaymentMethod, id=pk)
class AccountPMSerializer(serializers.ModelSerializer):
class Meta:
model = AccountPaymentMethod
fields = [
'id',
'account',
'type',
'display_name',
'is_default',
'is_enabled',
'is_verified',
'country_code',
'instructions',
'metadata',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'is_verified', 'created_at', 'updated_at']
if request.method.lower() == 'get':
return Response(AccountPMSerializer(obj).data)
if request.method.lower() in ['patch', 'put']:
partial = request.method.lower() == 'patch'
serializer = AccountPMSerializer(obj, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
if serializer.validated_data.get('is_default'):
AccountPaymentMethod.objects.filter(account=obj.account).exclude(id=obj.id).update(is_default=False)
return Response(AccountPMSerializer(obj).data)
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['post'], url_path='account_payment_method/set_default')
@extend_schema(tags=['Admin Billing'])
def set_default_account_payment_method(self, request, pk=None):
"""Set default account payment method (admin)"""
error = self._require_admin(request)
if error:
return error
obj = get_object_or_404(AccountPaymentMethod, id=pk)
AccountPaymentMethod.objects.filter(account=obj.account).update(is_default=False)
obj.is_default = True
obj.save(update_fields=['is_default'])
return Response({'message': 'Default payment method updated', 'id': obj.id})
@action(detail=False, methods=['get'])
def stats(self, request):
"""System billing stats"""

View File

@@ -94,9 +94,10 @@ class CreditPackageAdmin(admin.ModelAdmin):
@admin.register(PaymentMethodConfig)
class PaymentMethodConfigAdmin(admin.ModelAdmin):
list_display = ['country_code', 'payment_method', 'is_enabled', 'display_name', 'sort_order']
list_display = ['country_code', 'payment_method', 'display_name', 'is_enabled', 'sort_order', 'updated_at']
list_filter = ['payment_method', 'is_enabled', 'country_code']
search_fields = ['country_code', 'display_name', 'payment_method']
list_editable = ['is_enabled', 'sort_order']
readonly_fields = ['created_at', 'updated_at']

View File

@@ -19,6 +19,21 @@ urlpatterns = [
path('billing/pending_payments/', BillingAdminViewSet.as_view({'get': 'pending_payments'}), name='admin-billing-pending-payments'),
path('billing/<int:pk>/approve_payment/', BillingAdminViewSet.as_view({'post': 'approve_payment'}), name='admin-billing-approve-payment'),
path('billing/<int:pk>/reject_payment/', BillingAdminViewSet.as_view({'post': 'reject_payment'}), name='admin-billing-reject-payment'),
path('billing/payment-method-configs/', BillingAdminViewSet.as_view({'get': 'payment_method_configs', 'post': 'payment_method_configs'}), name='admin-billing-payment-method-configs'),
path('billing/payment-method-configs/<int:pk>/', BillingAdminViewSet.as_view({
'get': 'payment_method_config',
'patch': 'payment_method_config',
'put': 'payment_method_config',
'delete': 'payment_method_config',
}), name='admin-billing-payment-method-config'),
path('billing/account-payment-methods/', BillingAdminViewSet.as_view({'get': 'account_payment_methods', 'post': 'account_payment_methods'}), name='admin-billing-account-payment-methods'),
path('billing/account-payment-methods/<int:pk>/', BillingAdminViewSet.as_view({
'get': 'account_payment_method',
'patch': 'account_payment_method',
'put': 'account_payment_method',
'delete': 'account_payment_method',
}), name='admin-billing-account-payment-method'),
path('billing/account-payment-methods/<int:pk>/set_default/', BillingAdminViewSet.as_view({'post': 'set_default_account_payment_method'}), name='admin-billing-account-payment-method-set-default'),
]
urlpatterns += router.urls

View File

@@ -1,75 +0,0 @@
"""
Seed credit packages for testing
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from igny8_core.business.billing.models import CreditPackage
from decimal import Decimal
def seed_credit_packages():
"""Create default credit packages"""
packages = [
{
'name': 'Starter Pack',
'slug': 'starter-pack',
'credits': 1000,
'price': Decimal('9.99'),
'discount_percentage': 0,
'description': 'Perfect for trying out the platform',
'sort_order': 1,
'is_featured': False
},
{
'name': 'Professional Pack',
'slug': 'professional-pack',
'credits': 5000,
'price': Decimal('39.99'),
'discount_percentage': 20,
'description': 'Best for growing teams',
'sort_order': 2,
'is_featured': True
},
{
'name': 'Business Pack',
'slug': 'business-pack',
'credits': 15000,
'price': Decimal('99.99'),
'discount_percentage': 30,
'description': 'Ideal for established businesses',
'sort_order': 3,
'is_featured': False
},
{
'name': 'Enterprise Pack',
'slug': 'enterprise-pack',
'credits': 50000,
'price': Decimal('299.99'),
'discount_percentage': 40,
'description': 'Maximum value for high-volume users',
'sort_order': 4,
'is_featured': True
}
]
created_count = 0
for pkg_data in packages:
pkg, created = CreditPackage.objects.get_or_create(
slug=pkg_data['slug'],
defaults=pkg_data
)
if created:
created_count += 1
print(f"✅ Created: {pkg.name} - {pkg.credits:,} credits for ${pkg.price}")
else:
print(f"⏭️ Exists: {pkg.name}")
print(f"\n✅ Seeded {created_count} new credit packages")
print(f"📊 Total active packages: {CreditPackage.objects.filter(is_active=True).count()}")
if __name__ == '__main__':
seed_credit_packages()

View File

@@ -1,125 +0,0 @@
"""
Seed payment method configurations
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from igny8_core.business.billing.models import PaymentMethodConfig
def seed_payment_configs():
"""Create payment method configurations for various countries"""
configs = [
# United States - Stripe and PayPal only
{
'country_code': 'US',
'payment_method': 'stripe',
'is_enabled': True,
'display_name': 'Credit/Debit Card',
'instructions': 'Pay securely with your credit or debit card via Stripe',
'sort_order': 1
},
{
'country_code': 'US',
'payment_method': 'paypal',
'is_enabled': True,
'display_name': 'PayPal',
'instructions': 'Pay with your PayPal account',
'sort_order': 2
},
# India - All methods including manual
{
'country_code': 'IN',
'payment_method': 'stripe',
'is_enabled': True,
'display_name': 'Credit/Debit Card',
'instructions': 'Pay securely with your credit or debit card',
'sort_order': 1
},
{
'country_code': 'IN',
'payment_method': 'paypal',
'is_enabled': True,
'display_name': 'PayPal',
'instructions': 'Pay with your PayPal account',
'sort_order': 2
},
{
'country_code': 'IN',
'payment_method': 'bank_transfer',
'is_enabled': True,
'display_name': 'Bank Transfer (NEFT/IMPS/RTGS)',
'instructions': 'Transfer funds to our bank account. Payment will be verified within 1-2 business days.',
'bank_name': 'HDFC Bank',
'account_number': 'XXXXXXXXXXXXX',
'routing_number': 'HDFC0000XXX',
'swift_code': 'HDFCINBB',
'sort_order': 3
},
{
'country_code': 'IN',
'payment_method': 'local_wallet',
'is_enabled': True,
'display_name': 'UPI / Digital Wallet',
'instructions': 'Pay via Paytm, PhonePe, Google Pay, or other UPI apps. Upload payment screenshot for verification.',
'wallet_type': 'UPI',
'wallet_id': 'igny8@paytm',
'sort_order': 4
},
# United Kingdom - Stripe, PayPal, Bank Transfer
{
'country_code': 'GB',
'payment_method': 'stripe',
'is_enabled': True,
'display_name': 'Credit/Debit Card',
'instructions': 'Pay securely with your credit or debit card',
'sort_order': 1
},
{
'country_code': 'GB',
'payment_method': 'paypal',
'is_enabled': True,
'display_name': 'PayPal',
'instructions': 'Pay with your PayPal account',
'sort_order': 2
},
{
'country_code': 'GB',
'payment_method': 'bank_transfer',
'is_enabled': True,
'display_name': 'Bank Transfer (BACS/Faster Payments)',
'instructions': 'Transfer funds to our UK bank account.',
'bank_name': 'Barclays Bank',
'account_number': 'XXXXXXXX',
'routing_number': 'XX-XX-XX',
'swift_code': 'BARCGB22',
'sort_order': 3
},
]
created_count = 0
updated_count = 0
for config_data in configs:
config, created = PaymentMethodConfig.objects.update_or_create(
country_code=config_data['country_code'],
payment_method=config_data['payment_method'],
defaults={k: v for k, v in config_data.items() if k not in ['country_code', 'payment_method']}
)
if created:
created_count += 1
print(f"✅ Created: {config.country_code} - {config.get_payment_method_display()}")
else:
updated_count += 1
print(f"🔄 Updated: {config.country_code} - {config.get_payment_method_display()}")
print(f"\n✅ Created {created_count} configurations")
print(f"🔄 Updated {updated_count} configurations")
print(f"📊 Total active: {PaymentMethodConfig.objects.filter(is_enabled=True).count()}")
if __name__ == '__main__':
seed_payment_configs()

View File

@@ -1018,6 +1018,17 @@ class KeywordViewSet(SiteSectorModelViewSet):
- `GET /api/v1/admin/billing/pending_payments/` - Pending manual payments (admin review queue)
- `POST /api/v1/admin/billing/{id}/approve_payment/` - Approve manual payment (admin-only)
- `POST /api/v1/admin/billing/{id}/reject_payment/` - Reject manual payment (admin-only)
- `GET /api/v1/admin/billing/payment-method-configs/` - List payment method configs (country-level); query: `country_code`, `payment_method`
- `POST /api/v1/admin/billing/payment-method-configs/` - Create payment method config
- `GET /api/v1/admin/billing/payment-method-configs/{id}/` - Retrieve payment method config
- `PATCH/PUT /api/v1/admin/billing/payment-method-configs/{id}/` - Update payment method config
- `DELETE /api/v1/admin/billing/payment-method-configs/{id}/` - Delete payment method config
- `GET /api/v1/admin/billing/account-payment-methods/` - List account payment methods (query `account_id` to scope)
- `POST /api/v1/admin/billing/account-payment-methods/` - Create account payment method
- `GET /api/v1/admin/billing/account-payment-methods/{id}/` - Retrieve account payment method
- `PATCH/PUT /api/v1/admin/billing/account-payment-methods/{id}/` - Update account payment method
- `DELETE /api/v1/admin/billing/account-payment-methods/{id}/` - Delete account payment method
- `POST /api/v1/admin/billing/account-payment-methods/{id}/set_default/` - Set default account payment method
- `GET /api/v1/admin/credit-costs/` - List credit cost configurations (admin-only)
- `POST /api/v1/admin/credit-costs/` - Update credit cost configurations (admin-only)
- `GET /api/v1/admin/users/` - List users/accounts with credit info (admin-only)

View File

@@ -96,7 +96,7 @@ export default function PlansAndBillingPage() {
setPackages(packagesData.results || []);
setInvoices(invoicesData.results || []);
setPayments(paymentsData.results || []);
const methods = methodsData.results || [];
const methods = (methodsData.results || []).filter((m) => m.is_enabled !== false);
setPaymentMethods(methods);
if (methods.length > 0) {
const defaultMethod = methods.find((m) => m.is_default);
@@ -209,7 +209,7 @@ export default function PlansAndBillingPage() {
try {
await createPaymentMethod(newPaymentMethod as any);
toast?.success?.('Payment method added');
setNewPaymentMethod({ type: 'bank_transfer', display_name: '', instructions: '' });
setNewPaymentMethod({ type: 'bank_transfer', display_name: '', instructions: '' });
await loadData();
} catch (err: any) {
handleBillingError(err, 'Failed to add payment method');

View File

@@ -1,31 +1,85 @@
/**
* Admin All Payments Page
* View and manage all payment transactions
* Admin Payments Page
* Tabs: All Payments, Pending Approvals (approve/reject), Payment Methods (country-level configs + per-account methods)
*/
import { useState, useEffect } from 'react';
import { Search, Filter, Loader2, AlertCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Filter, Loader2, AlertCircle, Check, X, RefreshCw, Plus, Trash, Star } from 'lucide-react';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
import { getAdminPayments, type Payment } from '../../services/billing.api';
import {
getAdminPayments,
getPendingPayments,
approvePayment,
rejectPayment,
getAdminPaymentMethodConfigs,
createAdminPaymentMethodConfig,
updateAdminPaymentMethodConfig,
deleteAdminPaymentMethodConfig,
getAdminAccountPaymentMethods,
createAdminAccountPaymentMethod,
updateAdminAccountPaymentMethod,
deleteAdminAccountPaymentMethod,
setAdminDefaultAccountPaymentMethod,
getAdminUsers,
type Payment,
type PaymentMethod,
type PaymentMethodConfig,
type AdminAccountPaymentMethod,
type AdminUser,
} from '../../services/billing.api';
type AdminPayment = Payment & { account_name?: string };
type TabType = 'all' | 'pending' | 'methods';
export default function AdminAllPaymentsPage() {
const [payments, setPayments] = useState<AdminPayment[]>([]);
const [pendingPayments, setPendingPayments] = useState<AdminPayment[]>([]);
const [paymentConfigs, setPaymentConfigs] = useState<PaymentMethodConfig[]>([]);
const [accounts, setAccounts] = useState<AdminUser[]>([]);
const [accountPaymentMethods, setAccountPaymentMethods] = useState<AdminAccountPaymentMethod[]>([]);
const [accountIdFilter, setAccountIdFilter] = useState<string>('');
const [selectedConfigIdForAccount, setSelectedConfigIdForAccount] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>('');
const [statusFilter, setStatusFilter] = useState('all');
const [activeTab, setActiveTab] = useState<TabType>('all');
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
const [rejectNotes, setRejectNotes] = useState<Record<number, string>>({});
const [newConfig, setNewConfig] = useState<{
country_code: string;
payment_method: PaymentMethod['type'];
display_name: string;
instructions?: string;
sort_order?: number;
is_enabled?: boolean;
}>({
country_code: '*',
payment_method: 'bank_transfer',
display_name: '',
instructions: '',
sort_order: 0,
is_enabled: true,
});
const [editingConfigId, setEditingConfigId] = useState<number | null>(null);
useEffect(() => {
loadPayments();
loadAll();
}, []);
const loadPayments = async () => {
const loadAll = async () => {
try {
setLoading(true);
const data = await getAdminPayments();
setPayments(data.results || []);
const [allData, pendingData, configsData, usersData] = await Promise.all([
getAdminPayments(),
getPendingPayments(),
getAdminPaymentMethodConfigs(),
getAdminUsers(),
]);
setPayments(allData.results || []);
setPendingPayments(pendingData.results || []);
setPaymentConfigs(configsData.results || []);
setAccounts(usersData.results || []);
} catch (err: any) {
setError(err.message || 'Failed to load payments');
} finally {
@@ -33,9 +87,7 @@ export default function AdminAllPaymentsPage() {
}
};
const filteredPayments = payments.filter((payment) => {
return statusFilter === 'all' || payment.status === statusFilter;
});
const filteredPayments = payments.filter((payment) => statusFilter === 'all' || payment.status === statusFilter);
const getStatusColor = (status: string) => {
switch (status) {
@@ -53,6 +105,212 @@ export default function AdminAllPaymentsPage() {
}
};
const handleApprove = async (id: number) => {
try {
setActionLoadingId(id);
await approvePayment(id);
await loadAll();
} catch (err: any) {
setError(err.message || 'Failed to approve payment');
} finally {
setActionLoadingId(null);
}
};
const handleReject = async (id: number) => {
try {
setActionLoadingId(id);
await rejectPayment(id, { notes: rejectNotes[id] || '' });
await loadAll();
} catch (err: any) {
setError(err.message || 'Failed to reject payment');
} finally {
setActionLoadingId(null);
}
};
// Payment method configs (country-level)
const handleSaveConfig = async () => {
if (!newConfig.display_name.trim()) {
setError('Payment method display name is required');
return;
}
if (!newConfig.payment_method) {
setError('Payment method type is required');
return;
}
try {
setActionLoadingId(-1);
if (editingConfigId) {
await updateAdminPaymentMethodConfig(editingConfigId, {
country_code: newConfig.country_code || '*',
payment_method: newConfig.payment_method,
display_name: newConfig.display_name,
instructions: newConfig.instructions,
sort_order: newConfig.sort_order,
is_enabled: newConfig.is_enabled ?? true,
});
} else {
await createAdminPaymentMethodConfig({
country_code: newConfig.country_code || '*',
payment_method: newConfig.payment_method,
display_name: newConfig.display_name,
instructions: newConfig.instructions,
sort_order: newConfig.sort_order,
is_enabled: newConfig.is_enabled ?? true,
});
}
setNewConfig({
country_code: '*',
payment_method: 'bank_transfer',
display_name: '',
instructions: '',
sort_order: 0,
is_enabled: true,
});
setEditingConfigId(null);
const cfgs = await getAdminPaymentMethodConfigs();
setPaymentConfigs(cfgs.results || []);
} catch (err: any) {
setError(err.message || 'Failed to add payment method config');
} finally {
setActionLoadingId(null);
}
};
const handleToggleConfigEnabled = async (cfg: PaymentMethodConfig) => {
try {
setActionLoadingId(cfg.id);
await updateAdminPaymentMethodConfig(cfg.id, { is_enabled: !cfg.is_enabled });
const cfgs = await getAdminPaymentMethodConfigs();
setPaymentConfigs(cfgs.results || []);
} catch (err: any) {
setError(err.message || 'Failed to update payment method config');
} finally {
setActionLoadingId(null);
}
};
const handleDeleteConfig = async (id: number) => {
try {
setActionLoadingId(id);
await deleteAdminPaymentMethodConfig(id);
const cfgs = await getAdminPaymentMethodConfigs();
setPaymentConfigs(cfgs.results || []);
} catch (err: any) {
setError(err.message || 'Failed to delete payment method config');
} finally {
setActionLoadingId(null);
}
};
const handleEditConfig = (cfg: PaymentMethodConfig) => {
setEditingConfigId(cfg.id);
setNewConfig({
country_code: cfg.country_code,
payment_method: cfg.payment_method,
display_name: cfg.display_name,
instructions: cfg.instructions,
sort_order: cfg.sort_order,
is_enabled: cfg.is_enabled,
});
};
const handleCancelConfigEdit = () => {
setEditingConfigId(null);
setNewConfig({
country_code: '*',
payment_method: 'bank_transfer',
display_name: '',
instructions: '',
sort_order: 0,
is_enabled: true,
});
};
// Account payment methods
const handleLoadAccountMethods = async () => {
const accountId = accountIdFilter.trim();
if (!accountId) {
setAccountPaymentMethods([]);
return;
}
try {
setActionLoadingId(-3);
const data = await getAdminAccountPaymentMethods({ account_id: Number(accountId) });
setAccountPaymentMethods(data.results || []);
} catch (err: any) {
setError(err.message || 'Failed to load account payment methods');
} finally {
setActionLoadingId(null);
}
};
// Associate an existing country-level config to the account (one per account)
const handleAssociateConfigToAccount = async () => {
const accountId = accountIdFilter.trim();
if (!accountId) {
setError('Select an account first');
return;
}
if (!selectedConfigIdForAccount) {
setError('Select a payment method config to assign');
return;
}
const cfg = paymentConfigs.find((c) => c.id === selectedConfigIdForAccount);
if (!cfg) {
setError('Selected config not found');
return;
}
try {
setActionLoadingId(-2);
// Create or replace with the chosen config; treat as association.
const created = await createAdminAccountPaymentMethod({
account: Number(accountId),
type: cfg.payment_method,
display_name: cfg.display_name,
instructions: cfg.instructions,
is_enabled: cfg.is_enabled,
is_default: true,
});
// Remove extras if more than one exists for this account to enforce single association.
const refreshed = await getAdminAccountPaymentMethods({ account_id: Number(accountId) });
const others = (refreshed.results || []).filter((m) => m.id !== created.id);
for (const other of others) {
await deleteAdminAccountPaymentMethod(other.id);
}
await handleLoadAccountMethods();
} catch (err: any) {
setError(err.message || 'Failed to assign payment method to account');
} finally {
setActionLoadingId(null);
}
};
const handleDeleteAccountMethod = async (id: number | string) => {
try {
setActionLoadingId(Number(id));
await deleteAdminAccountPaymentMethod(id);
await handleLoadAccountMethods();
} catch (err: any) {
setError(err.message || 'Failed to delete account payment method');
} finally {
setActionLoadingId(null);
}
};
const handleSetDefaultAccountMethod = async (id: number | string) => {
try {
setActionLoadingId(Number(id));
await setAdminDefaultAccountPaymentMethod(id);
await handleLoadAccountMethods();
} catch (err: any) {
setError(err.message || 'Failed to set default account payment method');
} finally {
setActionLoadingId(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
@@ -61,13 +319,139 @@ export default function AdminAllPaymentsPage() {
);
}
const renderPaymentsTable = (rows: AdminPayment[]) => (
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Account</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Invoice</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Method</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">No payments found</td>
</tr>
) : (
rows.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-6 py-4 font-medium">{payment.account_name}</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{payment.invoice_number || payment.invoice_id || '—'}
</td>
<td className="px-6 py-4 font-semibold">{payment.currency} {payment.amount}</td>
<td className="px-6 py-4 text-sm capitalize">{payment.payment_method.replace('_', ' ')}</td>
<td className="px-6 py-4">
<Badge variant="light" color={getStatusColor(payment.status)}>
{payment.status}
</Badge>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(payment.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<button className="text-blue-600 hover:text-blue-700 text-sm">View</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
);
const renderPendingTable = () => (
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Account</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Invoice</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Method</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reference</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{pendingPayments.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">No pending payments</td>
</tr>
) : (
pendingPayments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-6 py-4 font-medium">{payment.account_name}</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{payment.invoice_number || payment.invoice_id || '—'}
</td>
<td className="px-6 py-4 font-semibold">{payment.currency} {payment.amount}</td>
<td className="px-6 py-4 text-sm capitalize">{payment.payment_method.replace('_', ' ')}</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{payment.transaction_reference || '—'}
</td>
<td className="px-6 py-4 text-right flex items-center justify-end gap-2">
<button
className="inline-flex items-center gap-1 text-green-600 hover:text-green-700 text-sm px-2 py-1 border border-green-200 rounded"
disabled={actionLoadingId === payment.id}
onClick={() => handleApprove(payment.id as number)}
>
{actionLoadingId === payment.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Approve
</button>
<div className="flex items-center gap-2">
<input
className="text-sm px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800"
placeholder="Rejection notes"
value={rejectNotes[payment.id as number] || ''}
onChange={(e) => setRejectNotes({ ...rejectNotes, [payment.id as number]: e.target.value })}
/>
<button
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 text-sm px-2 py-1 border border-red-200 rounded"
disabled={actionLoadingId === payment.id}
onClick={() => handleReject(payment.id as number)}
>
{actionLoadingId === payment.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
Reject
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
);
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">All Payments</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
View and manage all payment transactions
</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Payments</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Admin-only billing management
</p>
</div>
<button
onClick={loadAll}
className="inline-flex items-center gap-2 px-3 py-2 text-sm border border-gray-300 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-800"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
</div>
{error && (
@@ -77,74 +461,293 @@ export default function AdminAllPaymentsPage() {
</div>
)}
<div className="mb-6 flex items-center gap-2">
<Filter className="w-5 h-5 text-gray-400" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
>
<option value="all">All Status</option>
<option value="pending_approval">Pending Approval</option>
<option value="processing">Processing</option>
<option value="succeeded">Succeeded</option>
<option value="completed">Completed</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
<option value="refunded">Refunded</option>
</select>
<div className="mb-4 flex items-center gap-4">
<div className="flex gap-2">
<button
className={`px-3 py-2 rounded-lg text-sm border ${activeTab === 'all' ? 'border-blue-500 text-blue-600' : 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-300'}`}
onClick={() => setActiveTab('all')}
>
All Payments
</button>
<button
className={`px-3 py-2 rounded-lg text-sm border ${activeTab === 'pending' ? 'border-blue-500 text-blue-600' : 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-300'}`}
onClick={() => setActiveTab('pending')}
>
Pending Approvals
</button>
<button
className={`px-3 py-2 rounded-lg text-sm border ${activeTab === 'methods' ? 'border-blue-500 text-blue-600' : 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-300'}`}
onClick={() => setActiveTab('methods')}
>
Payment Methods
</button>
</div>
{activeTab === 'all' && (
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-gray-400" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
>
<option value="all">All Status</option>
<option value="pending_approval">Pending Approval</option>
<option value="processing">Processing</option>
<option value="succeeded">Succeeded</option>
<option value="completed">Completed</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
<option value="refunded">Refunded</option>
</select>
</div>
)}
</div>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Account</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Invoice</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Method</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredPayments.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">No payments found</td>
</tr>
) : (
filteredPayments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-6 py-4 font-medium">{payment.account_name}</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{payment.invoice_number || payment.invoice_id || '—'}
</td>
<td className="px-6 py-4 font-semibold">{payment.currency} {payment.amount}</td>
<td className="px-6 py-4 text-sm capitalize">{payment.payment_method.replace('_', ' ')}</td>
<td className="px-6 py-4">
<Badge
variant="light"
color={getStatusColor(payment.status)}
>
{payment.status}
</Badge>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(payment.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<button className="text-blue-600 hover:text-blue-700 text-sm">View</button>
</td>
</tr>
))
{activeTab === 'all' && renderPaymentsTable(filteredPayments)}
{activeTab === 'pending' && renderPendingTable()}
{activeTab === 'methods' && (
<div className="space-y-6">
{/* Payment Method Configs (country-level) */}
<Card className="p-4">
<h3 className="text-lg font-semibold mb-3">Payment Method Configs (country-level)</h3>
<div className="flex flex-col lg:flex-row gap-3">
<input
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded w-32"
placeholder="Country (e.g., *, US)"
value={newConfig.country_code}
onChange={(e) => setNewConfig({ ...newConfig, country_code: e.target.value })}
/>
<select
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded w-40"
value={newConfig.payment_method}
onChange={(e) => setNewConfig({ ...newConfig, payment_method: e.target.value as PaymentMethod['type'] })}
>
<option value="bank_transfer">Bank Transfer</option>
<option value="local_wallet">Manual (local wallet)</option>
<option value="stripe">Stripe</option>
<option value="paypal">PayPal</option>
</select>
<input
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded"
placeholder="Display name"
value={newConfig.display_name}
onChange={(e) => setNewConfig({ ...newConfig, display_name: e.target.value })}
/>
<input
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded"
placeholder="Instructions (optional)"
value={newConfig.instructions || ''}
onChange={(e) => setNewConfig({ ...newConfig, instructions: e.target.value })}
/>
<input
type="number"
className="w-28 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded"
placeholder="Sort"
value={newConfig.sort_order ?? 0}
onChange={(e) => setNewConfig({ ...newConfig, sort_order: Number(e.target.value) })}
/>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={!!newConfig.is_enabled}
onChange={(e) => setNewConfig({ ...newConfig, is_enabled: e.target.checked })}
/>
Enabled
</label>
<button
className="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
onClick={handleSaveConfig}
disabled={actionLoadingId === -1}
>
{actionLoadingId === -1 ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
{editingConfigId ? 'Save' : 'Add'}
</button>
{editingConfigId && (
<button
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50"
onClick={handleCancelConfigEdit}
disabled={actionLoadingId === -1}
>
Cancel
</button>
)}
</tbody>
</table>
</div>
</Card>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Country</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Enabled</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Instructions</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{paymentConfigs.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">No payment method configs</td>
</tr>
) : (
paymentConfigs.map((cfg) => (
<tr key={cfg.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-6 py-4 font-medium">{cfg.country_code}</td>
<td className="px-6 py-4 font-medium">{cfg.display_name}</td>
<td className="px-6 py-4 text-sm capitalize">{cfg.payment_method.replace('_', ' ')}</td>
<td className="px-6 py-4 text-sm">
<button
className="text-blue-600 hover:text-blue-700 text-sm mr-3"
onClick={() => handleToggleConfigEnabled(cfg)}
disabled={actionLoadingId === cfg.id}
>
{actionLoadingId === cfg.id ? <Loader2 className="w-4 h-4 animate-spin" /> : cfg.is_enabled ? 'Disable' : 'Enable'}
</button>
<button
className="text-blue-600 hover:text-blue-700 text-sm"
onClick={() => handleEditConfig(cfg)}
disabled={actionLoadingId === cfg.id}
>
Edit
</button>
</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{cfg.instructions || '—'}
</td>
<td className="px-6 py-4 text-right">
<button
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 text-sm px-2 py-1 border border-red-200 rounded"
onClick={() => handleDeleteConfig(cfg.id)}
disabled={actionLoadingId === cfg.id}
>
{actionLoadingId === cfg.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash className="w-4 h-4" />}
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
{/* Account Payment Methods (associate existing configs only) */}
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Account Payment Methods (association)</h3>
<div className="flex items-center gap-3">
<select
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded w-52"
value={accountIdFilter}
onChange={(e) => setAccountIdFilter(e.target.value)}
>
<option value="">Select account</option>
{accounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{acc.account_name || acc.email || acc.id}
</option>
))}
</select>
<button
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50"
onClick={handleLoadAccountMethods}
disabled={!accountIdFilter}
>
{actionLoadingId === -3 ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Load
</button>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-3 mb-4">
<select
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded w-64"
value={selectedConfigIdForAccount ?? ''}
onChange={(e) => setSelectedConfigIdForAccount(e.target.value ? Number(e.target.value) : null)}
disabled={!accountIdFilter}
>
<option value="">Select payment method config</option>
{paymentConfigs.map((cfg) => (
<option key={cfg.id} value={cfg.id}>
{cfg.display_name} ({cfg.payment_method.replace('_', ' ')} - {cfg.country_code})
</option>
))}
</select>
<button
className="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
onClick={handleAssociateConfigToAccount}
disabled={!accountIdFilter || !selectedConfigIdForAccount || actionLoadingId === -2}
>
{actionLoadingId === -2 ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Assign to account
</button>
<p className="text-sm text-gray-600 dark:text-gray-400">
Only one payment method per account; assigning replaces existing.
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Account</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Enabled</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Default</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Instructions</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{accountPaymentMethods.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">No account payment methods</td>
</tr>
) : (
accountPaymentMethods.map((m) => (
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-6 py-4 font-medium">{m.account}</td>
<td className="px-6 py-4 font-medium">{m.display_name}</td>
<td className="px-6 py-4 text-sm capitalize">{m.type.replace('_', ' ')}</td>
<td className="px-6 py-4 text-sm">{m.is_enabled ? 'Yes' : 'No'}</td>
<td className="px-6 py-4 text-sm">{m.is_default ? <Star className="w-4 h-4 text-yellow-500" /> : '—'}</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{m.instructions || '—'}
</td>
<td className="px-6 py-4 text-right space-x-2">
{!m.is_default && (
<button
className="text-blue-600 hover:text-blue-700 text-sm"
onClick={() => handleSetDefaultAccountMethod(m.id)}
disabled={actionLoadingId === Number(m.id)}
>
{actionLoadingId === Number(m.id) ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Set default'}
</button>
)}
<button
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 text-sm px-2 py-1 border border-red-200 rounded"
onClick={() => handleDeleteAccountMethod(m.id)}
disabled={actionLoadingId === Number(m.id)}
>
{actionLoadingId === Number(m.id) ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash className="w-4 h-4" />}
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
</Card>
)}
</div>
);
}

View File

@@ -4,10 +4,21 @@
*/
import { useState, useEffect } from 'react';
import {
Users, DollarSign, TrendingUp, AlertCircle,
CheckCircle, Clock, Activity, Loader2, ExternalLink,
Globe, Database, Folder, Server, GitBranch, FileText
import {
Users,
CheckCircle,
DollarSign,
Clock,
AlertCircle,
Activity,
Loader2,
ExternalLink,
Globe,
Database,
Folder,
Server,
GitBranch,
FileText,
} from 'lucide-react';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
@@ -17,8 +28,11 @@ export default function AdminSystemDashboard() {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<any>(null);
const [error, setError] = useState<string>('');
const issuedCredits = Number(stats?.credits_issued_30d || 0);
const usedCredits = Number(stats?.credits_used_30d || 0);
const totalUsers = Number(stats?.total_users ?? 0);
const activeUsers = Number(stats?.active_users ?? 0);
const issuedCredits = Number(stats?.total_credits_issued ?? stats?.credits_issued_30d ?? 0);
const usedCredits = Number(stats?.total_credits_used ?? stats?.credits_used_30d ?? 0);
const creditScale = Math.max(issuedCredits, usedCredits, 1);
const issuedPct = Math.min(100, Math.round((issuedCredits / creditScale) * 100));
const usedPct = Math.min(100, Math.round((usedCredits / creditScale) * 100));
@@ -65,7 +79,7 @@ export default function AdminSystemDashboard() {
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">System Dashboard</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Overview of system health, accounts, and revenue
Overview of system health and billing activity
</p>
</div>
@@ -81,12 +95,9 @@ export default function AdminSystemDashboard() {
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Accounts</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Users</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{stats?.total_accounts?.toLocaleString() || 0}
</div>
<div className="text-sm text-green-600 mt-1">
+{stats?.new_accounts_this_month || 0} this month
{totalUsers.toLocaleString()}
</div>
</div>
<Users className="w-12 h-12 text-blue-600 opacity-50" />
@@ -96,11 +107,10 @@ export default function AdminSystemDashboard() {
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Active Subscriptions</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Active Users</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{stats?.active_subscriptions?.toLocaleString() || 0}
{activeUsers.toLocaleString()}
</div>
<div className="text-sm text-gray-500 mt-1">paying customers</div>
</div>
<CheckCircle className="w-12 h-12 text-green-600 opacity-50" />
</div>
@@ -109,13 +119,11 @@ export default function AdminSystemDashboard() {
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Revenue This Month</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Credits Issued</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
${Number(stats?.revenue_this_month || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div className="text-sm text-green-600 mt-1">
<TrendingUp className="w-4 h-4 inline" /> +12% vs last month
{issuedCredits.toLocaleString()}
</div>
<div className="text-sm text-gray-500 mt-1">lifetime total</div>
</div>
<DollarSign className="w-12 h-12 text-green-600 opacity-50" />
</div>
@@ -124,11 +132,11 @@ export default function AdminSystemDashboard() {
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Pending Approvals</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Credits Used</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{stats?.pending_approvals || 0}
{usedCredits.toLocaleString()}
</div>
<div className="text-sm text-yellow-600 mt-1">requires attention</div>
<div className="text-sm text-gray-500 mt-1">lifetime total</div>
</div>
<Clock className="w-12 h-12 text-yellow-600 opacity-50" />
</div>
@@ -219,46 +227,6 @@ export default function AdminSystemDashboard() {
</div>
</Card>
{/* Recent Activity */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Recent Activity</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Account</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Description</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Amount</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{stats?.recent_activity?.map((activity: any, idx: number) => (
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="py-3 px-4">
<Badge variant="light" color={activity.type === 'purchase' ? 'success' : 'primary'}>
{activity.type}
</Badge>
</td>
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{activity.account_name}</td>
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{activity.description}</td>
<td className="py-3 px-4 text-sm text-right font-semibold">
{activity.currency} {activity.amount}
</td>
<td className="py-3 px-4 text-sm text-right text-gray-500">
{new Date(activity.timestamp).toLocaleTimeString()}
</td>
</tr>
)) || (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">No recent activity</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -162,12 +162,13 @@ export interface TeamMember {
export interface PaymentMethod {
id: string;
type: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet';
type: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet' | 'manual';
display_name: string;
name?: string;
is_enabled: boolean;
is_default?: boolean;
instructions?: string;
country_code?: string;
bank_details?: {
bank_name?: string;
account_number?: string;
@@ -297,6 +298,90 @@ export async function getAdminPayments(params?: { status?: string; account_id?:
return fetchAPI(url);
}
// Admin payment method configs (country-level)
export interface PaymentMethodConfig {
id: number;
country_code: string;
payment_method: PaymentMethod['type'];
display_name: string;
is_enabled: boolean;
instructions?: string;
sort_order?: number;
}
export async function getAdminPaymentMethodConfigs(params?: { country_code?: string; payment_method?: string }): Promise<{
results: PaymentMethodConfig[];
count: number;
}> {
const queryParams = new URLSearchParams();
if (params?.country_code) queryParams.append('country_code', params.country_code);
if (params?.payment_method) queryParams.append('payment_method', params.payment_method);
const url = `/v1/admin/billing/payment-method-configs/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return fetchAPI(url);
}
export async function createAdminPaymentMethodConfig(data: Partial<PaymentMethodConfig>): Promise<PaymentMethodConfig> {
return fetchAPI('/v1/admin/billing/payment-method-configs/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateAdminPaymentMethodConfig(id: number, data: Partial<PaymentMethodConfig>): Promise<PaymentMethodConfig> {
return fetchAPI(`/v1/admin/billing/payment-method-configs/${id}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
export async function deleteAdminPaymentMethodConfig(id: number): Promise<void> {
await fetchAPI(`/v1/admin/billing/payment-method-configs/${id}/`, {
method: 'DELETE',
});
}
// Admin account payment methods (cross-account)
export interface AdminAccountPaymentMethod extends PaymentMethod {
account: number;
metadata?: Record<string, any>;
}
export async function getAdminAccountPaymentMethods(params?: { account_id?: number }): Promise<{
results: AdminAccountPaymentMethod[];
count: number;
}> {
const queryParams = new URLSearchParams();
if (params?.account_id) queryParams.append('account_id', String(params.account_id));
const url = `/v1/admin/billing/account-payment-methods/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return fetchAPI(url);
}
export async function createAdminAccountPaymentMethod(data: Partial<AdminAccountPaymentMethod>): Promise<AdminAccountPaymentMethod> {
return fetchAPI('/v1/admin/billing/account-payment-methods/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateAdminAccountPaymentMethod(id: number | string, data: Partial<AdminAccountPaymentMethod>): Promise<AdminAccountPaymentMethod> {
return fetchAPI(`/v1/admin/billing/account-payment-methods/${id}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
export async function deleteAdminAccountPaymentMethod(id: number | string): Promise<void> {
await fetchAPI(`/v1/admin/billing/account-payment-methods/${id}/`, {
method: 'DELETE',
});
}
export async function setAdminDefaultAccountPaymentMethod(id: number | string): Promise<{ message: string; id: number | string }> {
return fetchAPI(`/v1/admin/billing/account-payment-methods/${id}/set_default/`, {
method: 'POST',
});
}
export async function getAdminUsers(params?: {
search?: string;
role?: string;