billing admin account 1

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-05 08:01:55 +00:00
parent f91037b729
commit 1e718105f2
12 changed files with 378 additions and 85 deletions

View File

@@ -222,7 +222,7 @@ class UsageAnalyticsViewSet(viewsets.ViewSet):
'period_days': days, 'period_days': days,
'start_date': start_date.isoformat(), 'start_date': start_date.isoformat(),
'end_date': timezone.now().isoformat(), 'end_date': timezone.now().isoformat(),
'current_balance': account.credit_balance, 'current_balance': account.credits,
'usage_by_type': list(usage_by_type), 'usage_by_type': list(usage_by_type),
'purchases_by_type': list(purchases_by_type), 'purchases_by_type': list(purchases_by_type),
'daily_usage': daily_usage, 'daily_usage': daily_usage,

View File

@@ -1,6 +1,7 @@
""" """
Billing Models for Credit System Billing Models for Credit System
""" """
from decimal import Decimal
from django.db import models from django.db import models
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.conf import settings from django.conf import settings
@@ -22,6 +23,11 @@ class CreditTransaction(AccountBaseModel):
balance_after = models.IntegerField(help_text="Credit balance after this transaction") balance_after = models.IntegerField(help_text="Credit balance after this transaction")
description = models.CharField(max_length=255) description = models.CharField(max_length=255)
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)") metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
reference_id = models.CharField(
max_length=255,
blank=True,
help_text="Optional reference (e.g., payment id, invoice id)"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@@ -183,9 +189,10 @@ class Invoice(AccountBaseModel):
) )
# Amounts # Amounts
subtotal = models.DecimalField(max_digits=10, decimal_places=2) subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0)
tax = models.DecimalField(max_digits=10, decimal_places=2, default=0) tax = models.DecimalField(max_digits=10, decimal_places=2, default=0)
total = models.DecimalField(max_digits=10, decimal_places=2) total = models.DecimalField(max_digits=10, decimal_places=2, default=0)
currency = models.CharField(max_length=3, default='USD')
# Status # Status
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True)
@@ -201,6 +208,9 @@ class Invoice(AccountBaseModel):
# Payment integration # Payment integration
stripe_invoice_id = models.CharField(max_length=255, null=True, blank=True) stripe_invoice_id = models.CharField(max_length=255, null=True, blank=True)
payment_method = models.CharField(max_length=50, null=True, blank=True) payment_method = models.CharField(max_length=50, null=True, blank=True)
billing_email = models.EmailField(null=True, blank=True)
billing_period_start = models.DateTimeField(null=True, blank=True)
billing_period_end = models.DateTimeField(null=True, blank=True)
# Metadata # Metadata
notes = models.TextField(blank=True) notes = models.TextField(blank=True)
@@ -222,6 +232,45 @@ class Invoice(AccountBaseModel):
def __str__(self): def __str__(self):
return f"Invoice {self.invoice_number} - {self.account.name if self.account else 'No Account'}" return f"Invoice {self.invoice_number} - {self.account.name if self.account else 'No Account'}"
# ------------------------------------------------------------------
# Helpers to keep service code working with legacy field names
# ------------------------------------------------------------------
@property
def subtotal_amount(self):
return self.subtotal
@property
def tax_amount(self):
return self.tax
@property
def total_amount(self):
return self.total
def add_line_item(self, description: str, quantity: int, unit_price: Decimal, amount: Decimal = None):
"""Append a line item and keep JSON shape consistent."""
items = list(self.line_items or [])
qty = quantity or 1
amt = Decimal(amount) if amount is not None else Decimal(unit_price) * qty
items.append({
'description': description,
'quantity': qty,
'unit_price': str(unit_price),
'amount': str(amt),
})
self.line_items = items
def calculate_totals(self):
"""Recompute subtotal, tax, and total from line_items."""
subtotal = Decimal('0')
for item in self.line_items or []:
try:
subtotal += Decimal(str(item.get('amount') or 0))
except Exception:
pass
self.subtotal = subtotal
self.total = subtotal + (self.tax or Decimal('0'))
class Payment(AccountBaseModel): class Payment(AccountBaseModel):
""" """
@@ -230,8 +279,10 @@ class Payment(AccountBaseModel):
""" """
STATUS_CHOICES = [ STATUS_CHOICES = [
('pending', 'Pending'), ('pending', 'Pending'),
('pending_approval', 'Pending Approval'),
('processing', 'Processing'), ('processing', 'Processing'),
('succeeded', 'Succeeded'), ('succeeded', 'Succeeded'),
('completed', 'Completed'), # Legacy alias for succeeded
('failed', 'Failed'), ('failed', 'Failed'),
('refunded', 'Refunded'), ('refunded', 'Refunded'),
('cancelled', 'Cancelled'), ('cancelled', 'Cancelled'),
@@ -272,6 +323,8 @@ class Payment(AccountBaseModel):
help_text="Bank transfer reference, wallet transaction ID, etc." help_text="Bank transfer reference, wallet transaction ID, etc."
) )
manual_notes = models.TextField(blank=True, help_text="Admin notes for manual payments") manual_notes = models.TextField(blank=True, help_text="Admin notes for manual payments")
transaction_reference = models.CharField(max_length=255, blank=True)
admin_notes = models.TextField(blank=True, help_text="Internal notes on approval/rejection")
approved_by = models.ForeignKey( approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
null=True, null=True,

View File

@@ -52,6 +52,8 @@ class InvoiceService:
billing_email=account.billing_email or account.users.filter(role='owner').first().email, billing_email=account.billing_email or account.users.filter(role='owner').first().email,
status='pending', status='pending',
currency='USD', currency='USD',
invoice_date=timezone.now().date(),
due_date=billing_period_end.date(),
billing_period_start=billing_period_start, billing_period_start=billing_period_start,
billing_period_end=billing_period_end billing_period_end=billing_period_end
) )
@@ -83,7 +85,13 @@ class InvoiceService:
invoice_number=InvoiceService.generate_invoice_number(account), invoice_number=InvoiceService.generate_invoice_number(account),
billing_email=account.billing_email or account.users.filter(role='owner').first().email, billing_email=account.billing_email or account.users.filter(role='owner').first().email,
status='pending', status='pending',
currency='USD' currency='USD',
invoice_date=timezone.now().date(),
due_date=timezone.now().date(),
metadata={
'credit_package_id': credit_package.id,
'credit_amount': credit_package.credits,
},
) )
# Add line item for credit package # Add line item for credit package
@@ -125,6 +133,7 @@ class InvoiceService:
status='draft', status='draft',
currency='USD', currency='USD',
notes=notes, notes=notes,
invoice_date=timezone.now().date(),
due_date=due_date or (timezone.now() + timedelta(days=30)) due_date=due_date or (timezone.now() + timedelta(days=30))
) )

View File

@@ -77,6 +77,12 @@ class PaymentService:
if payment_method not in ['bank_transfer', 'local_wallet', 'manual']: if payment_method not in ['bank_transfer', 'local_wallet', 'manual']:
raise ValueError("Invalid manual payment method") raise ValueError("Invalid manual payment method")
meta = metadata or {}
# propagate credit package metadata from invoice if present
if invoice.metadata.get('credit_package_id'):
meta.setdefault('credit_package_id', invoice.metadata.get('credit_package_id'))
meta.setdefault('credit_amount', invoice.metadata.get('credit_amount'))
payment = Payment.objects.create( payment = Payment.objects.create(
account=invoice.account, account=invoice.account,
invoice=invoice, invoice=invoice,
@@ -86,7 +92,7 @@ class PaymentService:
status='pending_approval', status='pending_approval',
transaction_reference=transaction_reference, transaction_reference=transaction_reference,
admin_notes=admin_notes, admin_notes=admin_notes,
metadata=metadata or {} metadata=meta
) )
return payment return payment
@@ -102,7 +108,7 @@ class PaymentService:
""" """
from .invoice_service import InvoiceService from .invoice_service import InvoiceService
payment.status = 'completed' payment.status = 'succeeded'
payment.processed_at = timezone.now() payment.processed_at = timezone.now()
if transaction_id: if transaction_id:
@@ -153,9 +159,10 @@ class PaymentService:
if payment.status != 'pending_approval': if payment.status != 'pending_approval':
raise ValueError("Payment is not pending approval") raise ValueError("Payment is not pending approval")
payment.status = 'completed' payment.status = 'succeeded'
payment.processed_at = timezone.now() payment.processed_at = timezone.now()
payment.approved_by_id = approved_by_user_id payment.approved_by_id = approved_by_user_id
payment.approved_at = timezone.now()
if admin_notes: if admin_notes:
payment.admin_notes = f"{payment.admin_notes}\n\nApproval notes: {admin_notes}" if payment.admin_notes else admin_notes payment.admin_notes = f"{payment.admin_notes}\n\nApproval notes: {admin_notes}" if payment.admin_notes else admin_notes
@@ -212,6 +219,11 @@ class PaymentService:
except CreditPackage.DoesNotExist: except CreditPackage.DoesNotExist:
return return
# Update account balance
account: Account = payment.account
account.credits = (account.credits or 0) + credit_package.credits
account.save(update_fields=['credits', 'updated_at'])
# Create credit transaction # Create credit transaction
CreditTransaction.objects.create( CreditTransaction.objects.create(
account=payment.account, account=payment.account,
@@ -244,14 +256,20 @@ class PaymentService:
return { return {
'methods': [ 'methods': [
{ {
'id': 'stripe-default',
'type': 'stripe', 'type': 'stripe',
'name': 'Credit/Debit Card', 'name': 'Credit/Debit Card',
'instructions': 'Pay securely with your credit or debit card' 'display_name': 'Credit/Debit Card',
'instructions': 'Pay securely with your credit or debit card',
'is_enabled': True,
}, },
{ {
'id': 'paypal-default',
'type': 'paypal', 'type': 'paypal',
'name': 'PayPal', 'name': 'PayPal',
'instructions': 'Pay with your PayPal account' 'display_name': 'PayPal',
'instructions': 'Pay with your PayPal account',
'is_enabled': True,
} }
], ],
'stripe': True, 'stripe': True,
@@ -272,9 +290,12 @@ class PaymentService:
for config in configs: for config in configs:
method_flags[config.payment_method] = True method_flags[config.payment_method] = True
method_data = { method_data = {
'id': f"{config.country_code}-{config.payment_method}-{config.id}",
'type': config.payment_method, 'type': config.payment_method,
'name': config.display_name or config.get_payment_method_display(), 'name': config.display_name or config.get_payment_method_display(),
'instructions': config.instructions 'display_name': config.display_name or config.get_payment_method_display(),
'instructions': config.instructions,
'is_enabled': True,
} }
# Add bank details if bank_transfer # Add bank details if bank_transfer
@@ -323,8 +344,8 @@ class PaymentService:
TODO: Implement actual refund logic for Stripe/PayPal TODO: Implement actual refund logic for Stripe/PayPal
For now, just mark as refunded For now, just mark as refunded
""" """
if payment.status != 'completed': if payment.status not in ['completed', 'succeeded']:
raise ValueError("Can only refund completed payments") raise ValueError("Can only refund succeeded/complete payments")
refund_amount = amount or payment.amount refund_amount = amount or payment.amount
@@ -338,8 +359,9 @@ class PaymentService:
amount=-refund_amount, # Negative amount for refund amount=-refund_amount, # Negative amount for refund
currency=payment.currency, currency=payment.currency,
payment_method=payment.payment_method, payment_method=payment.payment_method,
status='completed', status='refunded',
processed_at=timezone.now(), processed_at=timezone.now(),
refunded_at=timezone.now(),
metadata={ metadata={
'refund_for_payment_id': payment.id, 'refund_for_payment_id': payment.id,
'refund_reason': reason, 'refund_reason': reason,
@@ -348,10 +370,16 @@ class PaymentService:
) )
# Update original payment metadata # Update original payment metadata
payment.metadata['refunded'] = True meta = payment.metadata or {}
payment.metadata['refund_payment_id'] = refund.id meta['refunded'] = True
payment.metadata['refund_amount'] = str(refund_amount) meta['refund_payment_id'] = refund.id
payment.save() meta['refund_amount'] = str(refund_amount)
if reason:
meta['refund_reason'] = reason
payment.metadata = meta
payment.status = 'refunded'
payment.refunded_at = timezone.now()
payment.save(update_fields=['metadata', 'status', 'refunded_at', 'updated_at'])
return refund return refund

View File

@@ -8,6 +8,7 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.db import models
from .models import Invoice, Payment, CreditPackage, PaymentMethodConfig, CreditTransaction from .models import Invoice, Payment, CreditPackage, PaymentMethodConfig, CreditTransaction
from .services.invoice_service import InvoiceService from .services.invoice_service import InvoiceService
@@ -126,16 +127,21 @@ class PaymentViewSet(viewsets.ViewSet):
"""Get available payment methods for current account""" """Get available payment methods for current account"""
account = request.user.account account = request.user.account
methods = PaymentService.get_available_payment_methods(account) methods = PaymentService.get_available_payment_methods(account)
method_list = methods.pop('methods', [])
return Response(methods) return Response({
'results': method_list,
'count': len(method_list),
**methods
})
@action(detail=False, methods=['post']) @action(detail=False, methods=['post'], url_path='manual')
def create_manual_payment(self, request): def create_manual_payment(self, request):
"""Submit manual payment for approval""" """Submit manual payment for approval"""
account = request.user.account account = request.user.account
invoice_id = request.data.get('invoice_id') invoice_id = request.data.get('invoice_id')
payment_method = request.data.get('payment_method') # 'bank_transfer' or 'local_wallet' payment_method = request.data.get('payment_method') # 'bank_transfer' or 'local_wallet'
transaction_reference = request.data.get('transaction_reference') transaction_reference = request.data.get('transaction_reference') or request.data.get('reference')
notes = request.data.get('notes') notes = request.data.get('notes')
if not all([invoice_id, payment_method, transaction_reference]): if not all([invoice_id, payment_method, transaction_reference]):
@@ -261,22 +267,32 @@ class CreditTransactionViewSet(viewsets.ViewSet):
for txn in transactions for txn in transactions
], ],
'count': transactions.count(), 'count': transactions.count(),
'current_balance': account.credit_balance 'current_balance': account.credits
}) })
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def balance(self, request): def balance(self, request):
"""Get current credit balance""" """Get current credit balance"""
account = request.user.account account = request.user.account
from django.utils import timezone
# Get subscription details from datetime import timedelta
active_subscription = account.subscriptions.filter(status='active').first() now = timezone.now()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
used_this_month = abs(
CreditTransaction.objects.filter(
account=account,
created_at__gte=month_start,
amount__lt=0
).aggregate(total=models.Sum('amount'))['total'] or 0
)
plan = getattr(account, 'plan', None)
included = plan.included_credits if plan else 0
return Response({ return Response({
'balance': account.credit_balance, 'credits': account.credits,
'subscription_plan': active_subscription.plan.name if active_subscription else 'None', 'plan_credits_per_month': included,
'monthly_credits': active_subscription.plan.monthly_credits if active_subscription else 0, 'credits_used_this_month': used_this_month,
'subscription_status': active_subscription.status if active_subscription else None 'credits_remaining': max(account.credits, 0),
}) })
@@ -284,15 +300,95 @@ class AdminBillingViewSet(viewsets.ViewSet):
"""Admin billing management""" """Admin billing management"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get']) def _require_admin(self, request):
def pending_payments(self, request): if not request.user.is_staff and not getattr(request.user, 'is_superuser', False):
"""List payments pending approval"""
# Check admin permission
if not request.user.is_staff:
return Response( return Response(
{'error': 'Admin access required'}, {'error': 'Admin access required'},
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
return None
@action(detail=False, methods=['get'])
def invoices(self, request):
"""List invoices across all accounts (admin)"""
error = self._require_admin(request)
if error:
return error
status_filter = request.query_params.get('status')
account_id = request.query_params.get('account_id')
qs = Invoice.objects.all().select_related('account').order_by('-created_at')
if status_filter:
qs = qs.filter(status=status_filter)
if account_id:
qs = qs.filter(account_id=account_id)
invoices = qs[:200]
return Response({
'results': [
{
'id': inv.id,
'invoice_number': inv.invoice_number,
'status': inv.status,
'total_amount': str(getattr(inv, 'total_amount', inv.total)),
'subtotal': str(inv.subtotal),
'tax_amount': str(getattr(inv, 'tax_amount', inv.tax)),
'currency': inv.currency,
'created_at': inv.created_at.isoformat(),
'paid_at': inv.paid_at.isoformat() if inv.paid_at else None,
'due_date': inv.due_date.isoformat() if inv.due_date else None,
'line_items': inv.line_items,
'account_name': inv.account.name if inv.account else None,
}
for inv in invoices
],
'count': qs.count(),
})
@action(detail=False, methods=['get'])
def payments(self, request):
"""List payments across all accounts (admin)"""
error = self._require_admin(request)
if error:
return error
status_filter = request.query_params.get('status')
account_id = request.query_params.get('account_id')
payment_method = request.query_params.get('payment_method')
qs = Payment.objects.all().select_related('account', 'invoice').order_by('-created_at')
if status_filter:
qs = qs.filter(status=status_filter)
if account_id:
qs = qs.filter(account_id=account_id)
if payment_method:
qs = qs.filter(payment_method=payment_method)
payments = qs[:200]
return Response({
'results': [
{
'id': pay.id,
'account_name': pay.account.name if pay.account else None,
'amount': str(pay.amount),
'currency': pay.currency,
'status': pay.status,
'payment_method': pay.payment_method,
'created_at': pay.created_at.isoformat(),
'invoice_id': pay.invoice_id,
'invoice_number': pay.invoice.invoice_number if pay.invoice else None,
'transaction_reference': pay.transaction_reference,
}
for pay in payments
],
'count': qs.count(),
})
@action(detail=False, methods=['get'])
def pending_payments(self, request):
"""List payments pending approval"""
error = self._require_admin(request)
if error:
return error
payments = PaymentService.get_pending_approvals() payments = PaymentService.get_pending_approvals()
@@ -317,11 +413,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def approve_payment(self, request, pk=None): def approve_payment(self, request, pk=None):
"""Approve a manual payment""" """Approve a manual payment"""
if not request.user.is_staff: error = self._require_admin(request)
return Response( if error:
{'error': 'Admin access required'}, return error
status=status.HTTP_403_FORBIDDEN
)
payment = get_object_or_404(Payment, id=pk) payment = get_object_or_404(Payment, id=pk)
admin_notes = request.data.get('notes') admin_notes = request.data.get('notes')
@@ -347,11 +441,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def reject_payment(self, request, pk=None): def reject_payment(self, request, pk=None):
"""Reject a manual payment""" """Reject a manual payment"""
if not request.user.is_staff: error = self._require_admin(request)
return Response( if error:
{'error': 'Admin access required'}, return error
status=status.HTTP_403_FORBIDDEN
)
payment = get_object_or_404(Payment, id=pk) payment = get_object_or_404(Payment, id=pk)
rejection_reason = request.data.get('reason', 'No reason provided') rejection_reason = request.data.get('reason', 'No reason provided')
@@ -377,11 +469,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def stats(self, request): def stats(self, request):
"""System billing stats""" """System billing stats"""
if not request.user.is_staff: error = self._require_admin(request)
return Response( if error:
{'error': 'Admin access required'}, return error
status=status.HTTP_403_FORBIDDEN
)
from django.db.models import Sum, Count from django.db.models import Sum, Count
from ...auth.models import Account from ...auth.models import Account
@@ -407,12 +497,12 @@ class AdminBillingViewSet(viewsets.ViewSet):
# Revenue stats # Revenue stats
total_revenue = Payment.objects.filter( total_revenue = Payment.objects.filter(
status='completed', status__in=['completed', 'succeeded'],
amount__gt=0 amount__gt=0
).aggregate(total=Sum('amount'))['total'] or 0 ).aggregate(total=Sum('amount'))['total'] or 0
revenue_this_month = Payment.objects.filter( revenue_this_month = Payment.objects.filter(
status='completed', status__in=['completed', 'succeeded'],
processed_at__gte=this_month_start, processed_at__gte=this_month_start,
amount__gt=0 amount__gt=0
).aggregate(total=Sum('amount'))['total'] or 0 ).aggregate(total=Sum('amount'))['total'] or 0
@@ -430,9 +520,7 @@ class AdminBillingViewSet(viewsets.ViewSet):
).aggregate(total=Sum('amount'))['total'] or 0) ).aggregate(total=Sum('amount'))['total'] or 0)
# Payment/Invoice stats # Payment/Invoice stats
pending_approvals = Payment.objects.filter( pending_approvals = Payment.objects.filter(status='pending_approval').count()
status='pending_approval'
).count()
invoices_pending = Invoice.objects.filter(status='pending').count() invoices_pending = Invoice.objects.filter(status='pending').count()
invoices_overdue = Invoice.objects.filter( invoices_overdue = Invoice.objects.filter(
@@ -442,7 +530,7 @@ class AdminBillingViewSet(viewsets.ViewSet):
# Recent activity # Recent activity
recent_payments = Payment.objects.filter( recent_payments = Payment.objects.filter(
status='completed' status__in=['completed', 'succeeded']
).order_by('-processed_at')[:5] ).order_by('-processed_at')[:5]
recent_activity = [ recent_activity = [

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.2.8 on 2025-12-05 07:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0004_add_invoice_payment_models'),
]
operations = [
migrations.AddField(
model_name='credittransaction',
name='reference_id',
field=models.CharField(blank=True, help_text='Optional reference (e.g., payment id, invoice id)', max_length=255),
),
migrations.AddField(
model_name='invoice',
name='billing_email',
field=models.EmailField(blank=True, max_length=254, null=True),
),
migrations.AddField(
model_name='invoice',
name='billing_period_end',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='invoice',
name='billing_period_start',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='invoice',
name='currency',
field=models.CharField(default='USD', max_length=3),
),
migrations.AddField(
model_name='payment',
name='admin_notes',
field=models.TextField(blank=True, help_text='Internal notes on approval/rejection'),
),
migrations.AddField(
model_name='payment',
name='transaction_reference',
field=models.CharField(blank=True, max_length=255),
),
migrations.AlterField(
model_name='invoice',
name='subtotal',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
migrations.AlterField(
model_name='invoice',
name='total',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
migrations.AlterField(
model_name='payment',
name='status',
field=models.CharField(choices=[('pending', 'Pending'), ('pending_approval', 'Pending Approval'), ('processing', 'Processing'), ('succeeded', 'Succeeded'), ('completed', 'Completed'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20),
),
]

View File

@@ -65,7 +65,7 @@ const AdminBilling: React.FC = () => {
try { try {
setLoading(true); setLoading(true);
const [statsData, usersData, configsData] = await Promise.all([ const [statsData, usersData, configsData] = await Promise.all([
fetchAPI('/v1/admin/billing/stats/'), fetchAPI('/v1/billing/admin/stats/'),
fetchAPI('/v1/admin/users/?limit=100'), fetchAPI('/v1/admin/users/?limit=100'),
fetchAPI('/v1/admin/credit-costs/'), fetchAPI('/v1/admin/credit-costs/'),
]); ]);

View File

@@ -205,7 +205,7 @@ export default function AccountBillingPage() {
<h3 className="text-lg font-semibold mb-4">Quick Actions</h3> <h3 className="text-lg font-semibold mb-4">Quick Actions</h3>
<div className="space-y-2"> <div className="space-y-2">
<Link <Link
to="/account/purchase-credits" to="/account/credits/purchase"
className="block w-full bg-blue-600 text-white text-center py-2 px-4 rounded hover:bg-blue-700 transition-colors" className="block w-full bg-blue-600 text-white text-center py-2 px-4 rounded hover:bg-blue-700 transition-colors"
> >
Purchase Credits Purchase Credits

View File

@@ -104,6 +104,7 @@ export default function PurchaseCreditsPage() {
setError(''); setError('');
await createManualPayment({ await createManualPayment({
invoice_id: invoiceData?.invoice_id || invoiceData?.id,
amount: String(selectedPackage?.price || 0), amount: String(selectedPackage?.price || 0),
payment_method: selectedPaymentMethod as 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet', payment_method: selectedPaymentMethod as 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet',
reference: manualPaymentData.transaction_reference, reference: manualPaymentData.transaction_reference,
@@ -356,7 +357,7 @@ export default function PurchaseCreditsPage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{paymentMethods.map((method) => ( {paymentMethods.map((method) => (
<div <div
key={method.type} key={method.id || method.type}
onClick={() => setSelectedPaymentMethod(method.type)} onClick={() => setSelectedPaymentMethod(method.type)}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${ className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedPaymentMethod === method.type selectedPaymentMethod === method.type

View File

@@ -7,7 +7,7 @@ import { useState, useEffect } from 'react';
import { Search, Filter, Loader2, AlertCircle, Download } from 'lucide-react'; import { Search, Filter, Loader2, AlertCircle, Download } 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 { getInvoices, type Invoice } from '../../services/billing.api'; import { getAdminInvoices, type Invoice } from '../../services/billing.api';
export default function AdminAllInvoicesPage() { export default function AdminAllInvoicesPage() {
const [invoices, setInvoices] = useState<Invoice[]>([]); const [invoices, setInvoices] = useState<Invoice[]>([]);
@@ -23,7 +23,7 @@ export default function AdminAllInvoicesPage() {
const loadInvoices = async () => { const loadInvoices = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await getInvoices({}); const data = await getAdminInvoices({});
setInvoices(data.results || []); setInvoices(data.results || []);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to load invoices'); setError(err.message || 'Failed to load invoices');
@@ -99,6 +99,9 @@ export default function AdminAllInvoicesPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Invoice # Invoice #
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Account
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date Date
</th> </th>
@@ -126,6 +129,9 @@ export default function AdminAllInvoicesPage() {
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white"> <td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
{invoice.invoice_number} {invoice.invoice_number}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{invoice.account_name || '—'}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400"> <td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
{new Date(invoice.created_at).toLocaleDateString()} {new Date(invoice.created_at).toLocaleDateString()}
</td> </td>

View File

@@ -7,20 +7,12 @@ import { useState, useEffect } from 'react';
import { Search, Filter, Loader2, AlertCircle } from 'lucide-react'; import { Search, Filter, Loader2, AlertCircle } 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 { fetchAPI } from '../../services/api'; import { getAdminPayments, type Payment } from '../../services/billing.api';
interface Payment { type AdminPayment = Payment & { account_name?: string };
id: number;
account_name: string;
amount: string;
currency: string;
status: string;
payment_method: string;
created_at: string;
}
export default function AdminAllPaymentsPage() { export default function AdminAllPaymentsPage() {
const [payments, setPayments] = useState<Payment[]>([]); const [payments, setPayments] = useState<AdminPayment[]>([]);
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');
@@ -32,7 +24,7 @@ export default function AdminAllPaymentsPage() {
const loadPayments = async () => { const loadPayments = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await fetchAPI('/v1/admin/payments/'); const data = await getAdminPayments();
setPayments(data.results || []); setPayments(data.results || []);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to load payments'); setError(err.message || 'Failed to load payments');
@@ -45,6 +37,22 @@ export default function AdminAllPaymentsPage() {
return statusFilter === 'all' || payment.status === statusFilter; return statusFilter === 'all' || payment.status === statusFilter;
}); });
const getStatusColor = (status: string) => {
switch (status) {
case 'succeeded':
case 'completed':
return 'success';
case 'processing':
case 'pending':
case 'pending_approval':
return 'warning';
case 'refunded':
return 'info';
default:
return 'error';
}
};
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">
@@ -77,9 +85,13 @@ export default function AdminAllPaymentsPage() {
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" 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="all">All Status</option>
<option value="pending_approval">Pending Approval</option>
<option value="processing">Processing</option>
<option value="succeeded">Succeeded</option> <option value="succeeded">Succeeded</option>
<option value="completed">Completed</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="failed">Failed</option> <option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
<option value="refunded">Refunded</option> <option value="refunded">Refunded</option>
</select> </select>
</div> </div>
@@ -90,6 +102,7 @@ export default function AdminAllPaymentsPage() {
<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">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">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">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">Status</th>
@@ -106,15 +119,15 @@ export default function AdminAllPaymentsPage() {
filteredPayments.map((payment) => ( filteredPayments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800"> <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 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 font-semibold">{payment.currency} {payment.amount}</td>
<td className="px-6 py-4 text-sm">{payment.payment_method}</td> <td className="px-6 py-4 text-sm capitalize">{payment.payment_method.replace('_', ' ')}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<Badge <Badge
variant="light" variant="light"
color={ color={getStatusColor(payment.status)}
payment.status === 'succeeded' ? 'success' :
payment.status === 'pending' ? 'warning' : 'error'
}
> >
{payment.status} {payment.status}
</Badge> </Badge>

View File

@@ -111,14 +111,16 @@ export interface Invoice {
stripe_invoice_id?: string; stripe_invoice_id?: string;
billing_period_start?: string; billing_period_start?: string;
billing_period_end?: string; billing_period_end?: string;
account_name?: string;
} }
export interface Payment { export interface Payment {
id: number; id: number;
invoice_id: number; invoice_id: number;
invoice_number?: string;
amount: string; amount: string;
currency: string; currency: string;
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'refunded' | 'cancelled' | 'pending_approval'; status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'refunded' | 'cancelled' | 'pending_approval' | 'completed';
payment_method: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet' | 'manual'; payment_method: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet' | 'manual';
created_at: string; created_at: string;
processed_at?: string; processed_at?: string;
@@ -186,7 +188,7 @@ export interface PendingPayment extends Payment {
// ============================================================================ // ============================================================================
export async function getCreditBalance(): Promise<CreditBalance> { export async function getCreditBalance(): Promise<CreditBalance> {
return fetchAPI('/v1/billing/credits/balance/balance/'); return fetchAPI('/v1/billing/transactions/balance/');
} }
export async function getCreditTransactions(): Promise<{ export async function getCreditTransactions(): Promise<{
@@ -259,7 +261,33 @@ export async function getCreditUsageLimits(): Promise<{
// ============================================================================ // ============================================================================
export async function getAdminBillingStats(): Promise<AdminBillingStats> { export async function getAdminBillingStats(): Promise<AdminBillingStats> {
return fetchAPI('/v1/admin/billing/stats/'); return fetchAPI('/v1/billing/admin/stats/');
}
export async function getAdminInvoices(params?: { status?: string; account_id?: number; search?: string }): Promise<{
results: Invoice[];
count: number;
}> {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.account_id) queryParams.append('account_id', String(params.account_id));
if (params?.search) queryParams.append('search', params.search);
const url = `/v1/billing/admin/invoices/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return fetchAPI(url);
}
export async function getAdminPayments(params?: { status?: string; account_id?: number; payment_method?: string }): Promise<{
results: Payment[];
count: number;
}> {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.account_id) queryParams.append('account_id', String(params.account_id));
if (params?.payment_method) queryParams.append('payment_method', params.payment_method);
const url = `/v1/billing/admin/payments/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return fetchAPI(url);
} }
export async function getAdminUsers(params?: { export async function getAdminUsers(params?: {
@@ -376,7 +404,7 @@ export async function getPayments(params?: {
} }
export async function submitManualPayment(data: { export async function submitManualPayment(data: {
invoice_id: number; invoice_id?: number;
payment_method: 'bank_transfer' | 'local_wallet' | 'manual'; payment_method: 'bank_transfer' | 'local_wallet' | 'manual';
amount: string; amount: string;
currency?: string; currency?: string;
@@ -409,9 +437,12 @@ export async function purchaseCreditPackage(data: {
package_id: number; package_id: number;
payment_method: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet'; payment_method: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet';
}): Promise<{ }): Promise<{
id: number; invoice_id?: number;
status: string; invoice_number?: string;
total_amount?: string;
status?: string;
message?: string; message?: string;
next_action?: string;
stripe_client_secret?: string; stripe_client_secret?: string;
paypal_order_id?: string; paypal_order_id?: string;
}> { }> {
@@ -467,6 +498,7 @@ export async function getAvailablePaymentMethods(): Promise<{
} }
export async function createManualPayment(data: { export async function createManualPayment(data: {
invoice_id?: number;
amount: string; amount: string;
payment_method: string; payment_method: string;
reference: string; reference: string;
@@ -490,7 +522,7 @@ export async function getPendingPayments(): Promise<{
results: PendingPayment[]; results: PendingPayment[];
count: number; count: number;
}> { }> {
return fetchAPI('/v1/admin/payments/pending/'); return fetchAPI('/v1/billing/admin/pending_payments/');
} }
export async function approvePayment(paymentId: number, data?: { export async function approvePayment(paymentId: number, data?: {
@@ -499,7 +531,7 @@ export async function approvePayment(paymentId: number, data?: {
message: string; message: string;
payment: Payment; payment: Payment;
}> { }> {
return fetchAPI(`/v1/admin/payments/${paymentId}/approve/`, { return fetchAPI(`/v1/billing/admin/${paymentId}/approve_payment/`, {
method: 'POST', method: 'POST',
body: JSON.stringify(data || {}), body: JSON.stringify(data || {}),
}); });
@@ -512,7 +544,7 @@ export async function rejectPayment(paymentId: number, data: {
message: string; message: string;
payment: Payment; payment: Payment;
}> { }> {
return fetchAPI(`/v1/admin/payments/${paymentId}/reject/`, { return fetchAPI(`/v1/billing/admin/${paymentId}/reject_payment/`, {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });