billing and paymetn methods
This commit is contained in:
@@ -40,8 +40,6 @@ class Igny8AdminSite(admin.AdminSite):
|
|||||||
('billing', 'Invoice'),
|
('billing', 'Invoice'),
|
||||||
('billing', 'Payment'),
|
('billing', 'Payment'),
|
||||||
('billing', 'CreditPackage'),
|
('billing', 'CreditPackage'),
|
||||||
('billing', 'PaymentMethodConfig'),
|
|
||||||
('billing', 'AccountPaymentMethod'),
|
|
||||||
('billing', 'CreditCostConfig'),
|
('billing', 'CreditCostConfig'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -107,6 +105,12 @@ class Igny8AdminSite(admin.AdminSite):
|
|||||||
('automation', 'AutomationRun'),
|
('automation', 'AutomationRun'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
'Payments': {
|
||||||
|
'models': [
|
||||||
|
('billing', 'PaymentMethodConfig'),
|
||||||
|
('billing', 'AccountPaymentMethod'),
|
||||||
|
],
|
||||||
|
},
|
||||||
'Integrations & Sync': {
|
'Integrations & Sync': {
|
||||||
'models': [
|
'models': [
|
||||||
('integration', 'SiteIntegration'),
|
('integration', 'SiteIntegration'),
|
||||||
@@ -170,6 +174,7 @@ class Igny8AdminSite(admin.AdminSite):
|
|||||||
'Writer Module',
|
'Writer Module',
|
||||||
'Thinker Module',
|
'Thinker Module',
|
||||||
'System Configuration',
|
'System Configuration',
|
||||||
|
'Payments',
|
||||||
'Integrations & Sync',
|
'Integrations & Sync',
|
||||||
'Publishing',
|
'Publishing',
|
||||||
'Optimization',
|
'Optimization',
|
||||||
|
|||||||
@@ -545,6 +545,179 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
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'])
|
@action(detail=False, methods=['get'])
|
||||||
def stats(self, request):
|
def stats(self, request):
|
||||||
"""System billing stats"""
|
"""System billing stats"""
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ class CreditPackageAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(PaymentMethodConfig)
|
@admin.register(PaymentMethodConfig)
|
||||||
class PaymentMethodConfigAdmin(admin.ModelAdmin):
|
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']
|
list_filter = ['payment_method', 'is_enabled', 'country_code']
|
||||||
search_fields = ['country_code', 'display_name', 'payment_method']
|
search_fields = ['country_code', 'display_name', 'payment_method']
|
||||||
|
list_editable = ['is_enabled', 'sort_order']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ urlpatterns = [
|
|||||||
path('billing/pending_payments/', BillingAdminViewSet.as_view({'get': 'pending_payments'}), name='admin-billing-pending-payments'),
|
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>/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/<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
|
urlpatterns += router.urls
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -1018,6 +1018,17 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
- `GET /api/v1/admin/billing/pending_payments/` - Pending manual payments (admin review queue)
|
- `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}/approve_payment/` - Approve manual payment (admin-only)
|
||||||
- `POST /api/v1/admin/billing/{id}/reject_payment/` - Reject 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)
|
- `GET /api/v1/admin/credit-costs/` - List credit cost configurations (admin-only)
|
||||||
- `POST /api/v1/admin/credit-costs/` - Update 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)
|
- `GET /api/v1/admin/users/` - List users/accounts with credit info (admin-only)
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function PlansAndBillingPage() {
|
|||||||
setPackages(packagesData.results || []);
|
setPackages(packagesData.results || []);
|
||||||
setInvoices(invoicesData.results || []);
|
setInvoices(invoicesData.results || []);
|
||||||
setPayments(paymentsData.results || []);
|
setPayments(paymentsData.results || []);
|
||||||
const methods = methodsData.results || [];
|
const methods = (methodsData.results || []).filter((m) => m.is_enabled !== false);
|
||||||
setPaymentMethods(methods);
|
setPaymentMethods(methods);
|
||||||
if (methods.length > 0) {
|
if (methods.length > 0) {
|
||||||
const defaultMethod = methods.find((m) => m.is_default);
|
const defaultMethod = methods.find((m) => m.is_default);
|
||||||
|
|||||||
@@ -1,31 +1,85 @@
|
|||||||
/**
|
/**
|
||||||
* Admin All Payments Page
|
* Admin Payments Page
|
||||||
* View and manage all payment transactions
|
* Tabs: All Payments, Pending Approvals (approve/reject), Payment Methods (country-level configs + per-account methods)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Search, Filter, Loader2, AlertCircle } from 'lucide-react';
|
import { Filter, Loader2, AlertCircle, Check, X, RefreshCw, Plus, Trash, Star } from 'lucide-react';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
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 AdminPayment = Payment & { account_name?: string };
|
||||||
|
type TabType = 'all' | 'pending' | 'methods';
|
||||||
|
|
||||||
export default function AdminAllPaymentsPage() {
|
export default function AdminAllPaymentsPage() {
|
||||||
const [payments, setPayments] = useState<AdminPayment[]>([]);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
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(() => {
|
useEffect(() => {
|
||||||
loadPayments();
|
loadAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadPayments = async () => {
|
const loadAll = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getAdminPayments();
|
const [allData, pendingData, configsData, usersData] = await Promise.all([
|
||||||
setPayments(data.results || []);
|
getAdminPayments(),
|
||||||
|
getPendingPayments(),
|
||||||
|
getAdminPaymentMethodConfigs(),
|
||||||
|
getAdminUsers(),
|
||||||
|
]);
|
||||||
|
setPayments(allData.results || []);
|
||||||
|
setPendingPayments(pendingData.results || []);
|
||||||
|
setPaymentConfigs(configsData.results || []);
|
||||||
|
setAccounts(usersData.results || []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load payments');
|
setError(err.message || 'Failed to load payments');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -33,9 +87,7 @@ export default function AdminAllPaymentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredPayments = payments.filter((payment) => {
|
const filteredPayments = payments.filter((payment) => statusFilter === 'all' || payment.status === statusFilter);
|
||||||
return statusFilter === 'all' || payment.status === statusFilter;
|
|
||||||
});
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
@@ -61,14 +319,140 @@ 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 (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">All Payments</h1>
|
<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">
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
View and manage all payment transactions
|
Admin-only billing management
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-3">
|
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-3">
|
||||||
@@ -77,7 +461,29 @@ export default function AdminAllPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-6 flex items-center gap-2">
|
<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" />
|
<Filter className="w-5 h-5 text-gray-400" />
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
@@ -95,48 +501,243 @@ export default function AdminAllPaymentsPage() {
|
|||||||
<option value="refunded">Refunded</option>
|
<option value="refunded">Refunded</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
<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">Country</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">Name</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">Type</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">Enabled</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">Instructions</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>
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{filteredPayments.length === 0 ? (
|
{paymentConfigs.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">No payments found</td>
|
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">No payment method configs</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredPayments.map((payment) => (
|
paymentConfigs.map((cfg) => (
|
||||||
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
<tr key={cfg.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 font-medium">{cfg.country_code}</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
<td className="px-6 py-4 font-medium">{cfg.display_name}</td>
|
||||||
{payment.invoice_number || payment.invoice_id || '—'}
|
<td className="px-6 py-4 text-sm capitalize">{cfg.payment_method.replace('_', ' ')}</td>
|
||||||
</td>
|
<td className="px-6 py-4 text-sm">
|
||||||
<td className="px-6 py-4 font-semibold">{payment.currency} {payment.amount}</td>
|
<button
|
||||||
<td className="px-6 py-4 text-sm capitalize">{payment.payment_method.replace('_', ' ')}</td>
|
className="text-blue-600 hover:text-blue-700 text-sm mr-3"
|
||||||
<td className="px-6 py-4">
|
onClick={() => handleToggleConfigEnabled(cfg)}
|
||||||
<Badge
|
disabled={actionLoadingId === cfg.id}
|
||||||
variant="light"
|
|
||||||
color={getStatusColor(payment.status)}
|
|
||||||
>
|
>
|
||||||
{payment.status}
|
{actionLoadingId === cfg.id ? <Loader2 className="w-4 h-4 animate-spin" /> : cfg.is_enabled ? 'Disable' : 'Enable'}
|
||||||
</Badge>
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-blue-600 hover:text-blue-700 text-sm"
|
||||||
|
onClick={() => handleEditConfig(cfg)}
|
||||||
|
disabled={actionLoadingId === cfg.id}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||||
{new Date(payment.created_at).toLocaleDateString()}
|
{cfg.instructions || '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<button className="text-blue-600 hover:text-blue-700 text-sm">View</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={() => 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -146,5 +747,7 @@ export default function AdminAllPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,20 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Users, DollarSign, TrendingUp, AlertCircle,
|
Users,
|
||||||
CheckCircle, Clock, Activity, Loader2, ExternalLink,
|
CheckCircle,
|
||||||
Globe, Database, Folder, Server, GitBranch, FileText
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
Activity,
|
||||||
|
Loader2,
|
||||||
|
ExternalLink,
|
||||||
|
Globe,
|
||||||
|
Database,
|
||||||
|
Folder,
|
||||||
|
Server,
|
||||||
|
GitBranch,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
@@ -17,8 +28,11 @@ export default function AdminSystemDashboard() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [stats, setStats] = useState<any>(null);
|
const [stats, setStats] = useState<any>(null);
|
||||||
const [error, setError] = useState<string>('');
|
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 creditScale = Math.max(issuedCredits, usedCredits, 1);
|
||||||
const issuedPct = Math.min(100, Math.round((issuedCredits / creditScale) * 100));
|
const issuedPct = Math.min(100, Math.round((issuedCredits / creditScale) * 100));
|
||||||
const usedPct = Math.min(100, Math.round((usedCredits / creditScale) * 100));
|
const usedPct = Math.min(100, Math.round((usedCredits / creditScale) * 100));
|
||||||
@@ -65,7 +79,7 @@ export default function AdminSystemDashboard() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">System Dashboard</h1>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,12 +95,9 @@ export default function AdminSystemDashboard() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
{stats?.total_accounts?.toLocaleString() || 0}
|
{totalUsers.toLocaleString()}
|
||||||
</div>
|
|
||||||
<div className="text-sm text-green-600 mt-1">
|
|
||||||
+{stats?.new_accounts_this_month || 0} this month
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Users className="w-12 h-12 text-blue-600 opacity-50" />
|
<Users className="w-12 h-12 text-blue-600 opacity-50" />
|
||||||
@@ -96,11 +107,10 @@ export default function AdminSystemDashboard() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
{stats?.active_subscriptions?.toLocaleString() || 0}
|
{activeUsers.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 mt-1">paying customers</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CheckCircle className="w-12 h-12 text-green-600 opacity-50" />
|
<CheckCircle className="w-12 h-12 text-green-600 opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -109,13 +119,11 @@ export default function AdminSystemDashboard() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
${Number(stats?.revenue_this_month || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
{issuedCredits.toLocaleString()}
|
||||||
</div>
|
|
||||||
<div className="text-sm text-green-600 mt-1">
|
|
||||||
<TrendingUp className="w-4 h-4 inline" /> +12% vs last month
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">lifetime total</div>
|
||||||
</div>
|
</div>
|
||||||
<DollarSign className="w-12 h-12 text-green-600 opacity-50" />
|
<DollarSign className="w-12 h-12 text-green-600 opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -124,11 +132,11 @@ export default function AdminSystemDashboard() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
{stats?.pending_approvals || 0}
|
{usedCredits.toLocaleString()}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<Clock className="w-12 h-12 text-yellow-600 opacity-50" />
|
<Clock className="w-12 h-12 text-yellow-600 opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -219,46 +227,6 @@ export default function AdminSystemDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,12 +162,13 @@ export interface TeamMember {
|
|||||||
|
|
||||||
export interface PaymentMethod {
|
export interface PaymentMethod {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet';
|
type: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet' | 'manual';
|
||||||
display_name: string;
|
display_name: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
|
country_code?: string;
|
||||||
bank_details?: {
|
bank_details?: {
|
||||||
bank_name?: string;
|
bank_name?: string;
|
||||||
account_number?: string;
|
account_number?: string;
|
||||||
@@ -297,6 +298,90 @@ export async function getAdminPayments(params?: { status?: string; account_id?:
|
|||||||
return fetchAPI(url);
|
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?: {
|
export async function getAdminUsers(params?: {
|
||||||
search?: string;
|
search?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user