billing admin account 1
This commit is contained in:
@@ -222,7 +222,7 @@ class UsageAnalyticsViewSet(viewsets.ViewSet):
|
||||
'period_days': days,
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': timezone.now().isoformat(),
|
||||
'current_balance': account.credit_balance,
|
||||
'current_balance': account.credits,
|
||||
'usage_by_type': list(usage_by_type),
|
||||
'purchases_by_type': list(purchases_by_type),
|
||||
'daily_usage': daily_usage,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Billing Models for Credit System
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.conf import settings
|
||||
@@ -22,6 +23,11 @@ class CreditTransaction(AccountBaseModel):
|
||||
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
|
||||
description = models.CharField(max_length=255)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
@@ -183,9 +189,10 @@ class Invoice(AccountBaseModel):
|
||||
)
|
||||
|
||||
# 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)
|
||||
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 = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True)
|
||||
@@ -201,6 +208,9 @@ class Invoice(AccountBaseModel):
|
||||
# Payment integration
|
||||
stripe_invoice_id = models.CharField(max_length=255, 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
|
||||
notes = models.TextField(blank=True)
|
||||
@@ -222,6 +232,45 @@ class Invoice(AccountBaseModel):
|
||||
def __str__(self):
|
||||
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):
|
||||
"""
|
||||
@@ -230,8 +279,10 @@ class Payment(AccountBaseModel):
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('pending_approval', 'Pending Approval'),
|
||||
('processing', 'Processing'),
|
||||
('succeeded', 'Succeeded'),
|
||||
('completed', 'Completed'), # Legacy alias for succeeded
|
||||
('failed', 'Failed'),
|
||||
('refunded', 'Refunded'),
|
||||
('cancelled', 'Cancelled'),
|
||||
@@ -272,6 +323,8 @@ class Payment(AccountBaseModel):
|
||||
help_text="Bank transfer reference, wallet transaction ID, etc."
|
||||
)
|
||||
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(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
|
||||
@@ -52,6 +52,8 @@ class InvoiceService:
|
||||
billing_email=account.billing_email or account.users.filter(role='owner').first().email,
|
||||
status='pending',
|
||||
currency='USD',
|
||||
invoice_date=timezone.now().date(),
|
||||
due_date=billing_period_end.date(),
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end
|
||||
)
|
||||
@@ -83,7 +85,13 @@ class InvoiceService:
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
billing_email=account.billing_email or account.users.filter(role='owner').first().email,
|
||||
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
|
||||
@@ -125,6 +133,7 @@ class InvoiceService:
|
||||
status='draft',
|
||||
currency='USD',
|
||||
notes=notes,
|
||||
invoice_date=timezone.now().date(),
|
||||
due_date=due_date or (timezone.now() + timedelta(days=30))
|
||||
)
|
||||
|
||||
|
||||
@@ -77,6 +77,12 @@ class PaymentService:
|
||||
if payment_method not in ['bank_transfer', 'local_wallet', 'manual']:
|
||||
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(
|
||||
account=invoice.account,
|
||||
invoice=invoice,
|
||||
@@ -86,7 +92,7 @@ class PaymentService:
|
||||
status='pending_approval',
|
||||
transaction_reference=transaction_reference,
|
||||
admin_notes=admin_notes,
|
||||
metadata=metadata or {}
|
||||
metadata=meta
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -102,7 +108,7 @@ class PaymentService:
|
||||
"""
|
||||
from .invoice_service import InvoiceService
|
||||
|
||||
payment.status = 'completed'
|
||||
payment.status = 'succeeded'
|
||||
payment.processed_at = timezone.now()
|
||||
|
||||
if transaction_id:
|
||||
@@ -153,9 +159,10 @@ class PaymentService:
|
||||
if payment.status != 'pending_approval':
|
||||
raise ValueError("Payment is not pending approval")
|
||||
|
||||
payment.status = 'completed'
|
||||
payment.status = 'succeeded'
|
||||
payment.processed_at = timezone.now()
|
||||
payment.approved_by_id = approved_by_user_id
|
||||
payment.approved_at = timezone.now()
|
||||
|
||||
if 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:
|
||||
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
|
||||
CreditTransaction.objects.create(
|
||||
account=payment.account,
|
||||
@@ -244,14 +256,20 @@ class PaymentService:
|
||||
return {
|
||||
'methods': [
|
||||
{
|
||||
'id': 'stripe-default',
|
||||
'type': 'stripe',
|
||||
'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',
|
||||
'name': 'PayPal',
|
||||
'instructions': 'Pay with your PayPal account'
|
||||
'display_name': 'PayPal',
|
||||
'instructions': 'Pay with your PayPal account',
|
||||
'is_enabled': True,
|
||||
}
|
||||
],
|
||||
'stripe': True,
|
||||
@@ -272,9 +290,12 @@ class PaymentService:
|
||||
for config in configs:
|
||||
method_flags[config.payment_method] = True
|
||||
method_data = {
|
||||
'id': f"{config.country_code}-{config.payment_method}-{config.id}",
|
||||
'type': config.payment_method,
|
||||
'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
|
||||
@@ -323,8 +344,8 @@ class PaymentService:
|
||||
TODO: Implement actual refund logic for Stripe/PayPal
|
||||
For now, just mark as refunded
|
||||
"""
|
||||
if payment.status != 'completed':
|
||||
raise ValueError("Can only refund completed payments")
|
||||
if payment.status not in ['completed', 'succeeded']:
|
||||
raise ValueError("Can only refund succeeded/complete payments")
|
||||
|
||||
refund_amount = amount or payment.amount
|
||||
|
||||
@@ -338,8 +359,9 @@ class PaymentService:
|
||||
amount=-refund_amount, # Negative amount for refund
|
||||
currency=payment.currency,
|
||||
payment_method=payment.payment_method,
|
||||
status='completed',
|
||||
status='refunded',
|
||||
processed_at=timezone.now(),
|
||||
refunded_at=timezone.now(),
|
||||
metadata={
|
||||
'refund_for_payment_id': payment.id,
|
||||
'refund_reason': reason,
|
||||
@@ -348,10 +370,16 @@ class PaymentService:
|
||||
)
|
||||
|
||||
# Update original payment metadata
|
||||
payment.metadata['refunded'] = True
|
||||
payment.metadata['refund_payment_id'] = refund.id
|
||||
payment.metadata['refund_amount'] = str(refund_amount)
|
||||
payment.save()
|
||||
meta = payment.metadata or {}
|
||||
meta['refunded'] = True
|
||||
meta['refund_payment_id'] = refund.id
|
||||
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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db import models
|
||||
|
||||
from .models import Invoice, Payment, CreditPackage, PaymentMethodConfig, CreditTransaction
|
||||
from .services.invoice_service import InvoiceService
|
||||
@@ -126,16 +127,21 @@ class PaymentViewSet(viewsets.ViewSet):
|
||||
"""Get available payment methods for current account"""
|
||||
account = request.user.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):
|
||||
"""Submit manual payment for approval"""
|
||||
account = request.user.account
|
||||
invoice_id = request.data.get('invoice_id')
|
||||
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')
|
||||
|
||||
if not all([invoice_id, payment_method, transaction_reference]):
|
||||
@@ -261,22 +267,32 @@ class CreditTransactionViewSet(viewsets.ViewSet):
|
||||
for txn in transactions
|
||||
],
|
||||
'count': transactions.count(),
|
||||
'current_balance': account.credit_balance
|
||||
'current_balance': account.credits
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def balance(self, request):
|
||||
"""Get current credit balance"""
|
||||
account = request.user.account
|
||||
|
||||
# Get subscription details
|
||||
active_subscription = account.subscriptions.filter(status='active').first()
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
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({
|
||||
'balance': account.credit_balance,
|
||||
'subscription_plan': active_subscription.plan.name if active_subscription else 'None',
|
||||
'monthly_credits': active_subscription.plan.monthly_credits if active_subscription else 0,
|
||||
'subscription_status': active_subscription.status if active_subscription else None
|
||||
'credits': account.credits,
|
||||
'plan_credits_per_month': included,
|
||||
'credits_used_this_month': used_this_month,
|
||||
'credits_remaining': max(account.credits, 0),
|
||||
})
|
||||
|
||||
|
||||
@@ -284,15 +300,95 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
"""Admin billing management"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def pending_payments(self, request):
|
||||
"""List payments pending approval"""
|
||||
# Check admin permission
|
||||
if not request.user.is_staff:
|
||||
def _require_admin(self, request):
|
||||
if not request.user.is_staff and not getattr(request.user, 'is_superuser', False):
|
||||
return Response(
|
||||
{'error': 'Admin access required'},
|
||||
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()
|
||||
|
||||
@@ -317,11 +413,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
@action(detail=True, methods=['post'])
|
||||
def approve_payment(self, request, pk=None):
|
||||
"""Approve a manual payment"""
|
||||
if not request.user.is_staff:
|
||||
return Response(
|
||||
{'error': 'Admin access required'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
error = self._require_admin(request)
|
||||
if error:
|
||||
return error
|
||||
|
||||
payment = get_object_or_404(Payment, id=pk)
|
||||
admin_notes = request.data.get('notes')
|
||||
@@ -347,11 +441,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
@action(detail=True, methods=['post'])
|
||||
def reject_payment(self, request, pk=None):
|
||||
"""Reject a manual payment"""
|
||||
if not request.user.is_staff:
|
||||
return Response(
|
||||
{'error': 'Admin access required'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
error = self._require_admin(request)
|
||||
if error:
|
||||
return error
|
||||
|
||||
payment = get_object_or_404(Payment, id=pk)
|
||||
rejection_reason = request.data.get('reason', 'No reason provided')
|
||||
@@ -377,11 +469,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
@action(detail=False, methods=['get'])
|
||||
def stats(self, request):
|
||||
"""System billing stats"""
|
||||
if not request.user.is_staff:
|
||||
return Response(
|
||||
{'error': 'Admin access required'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
error = self._require_admin(request)
|
||||
if error:
|
||||
return error
|
||||
|
||||
from django.db.models import Sum, Count
|
||||
from ...auth.models import Account
|
||||
@@ -407,12 +497,12 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
|
||||
# Revenue stats
|
||||
total_revenue = Payment.objects.filter(
|
||||
status='completed',
|
||||
status__in=['completed', 'succeeded'],
|
||||
amount__gt=0
|
||||
).aggregate(total=Sum('amount'))['total'] or 0
|
||||
|
||||
revenue_this_month = Payment.objects.filter(
|
||||
status='completed',
|
||||
status__in=['completed', 'succeeded'],
|
||||
processed_at__gte=this_month_start,
|
||||
amount__gt=0
|
||||
).aggregate(total=Sum('amount'))['total'] or 0
|
||||
@@ -430,9 +520,7 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
).aggregate(total=Sum('amount'))['total'] or 0)
|
||||
|
||||
# Payment/Invoice stats
|
||||
pending_approvals = Payment.objects.filter(
|
||||
status='pending_approval'
|
||||
).count()
|
||||
pending_approvals = Payment.objects.filter(status='pending_approval').count()
|
||||
|
||||
invoices_pending = Invoice.objects.filter(status='pending').count()
|
||||
invoices_overdue = Invoice.objects.filter(
|
||||
@@ -442,7 +530,7 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
|
||||
# Recent activity
|
||||
recent_payments = Payment.objects.filter(
|
||||
status='completed'
|
||||
status__in=['completed', 'succeeded']
|
||||
).order_by('-processed_at')[:5]
|
||||
|
||||
recent_activity = [
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -65,7 +65,7 @@ const AdminBilling: React.FC = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
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/credit-costs/'),
|
||||
]);
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function AccountBillingPage() {
|
||||
<h3 className="text-lg font-semibold mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<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"
|
||||
>
|
||||
Purchase Credits
|
||||
|
||||
@@ -104,6 +104,7 @@ export default function PurchaseCreditsPage() {
|
||||
setError('');
|
||||
|
||||
await createManualPayment({
|
||||
invoice_id: invoiceData?.invoice_id || invoiceData?.id,
|
||||
amount: String(selectedPackage?.price || 0),
|
||||
payment_method: selectedPaymentMethod as 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet',
|
||||
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">
|
||||
{paymentMethods.map((method) => (
|
||||
<div
|
||||
key={method.type}
|
||||
key={method.id || method.type}
|
||||
onClick={() => setSelectedPaymentMethod(method.type)}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedPaymentMethod === method.type
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Search, Filter, Loader2, AlertCircle, Download } from 'lucide-react';
|
||||
import { Card } from '../../components/ui/card';
|
||||
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() {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
@@ -23,7 +23,7 @@ export default function AdminAllInvoicesPage() {
|
||||
const loadInvoices = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getInvoices({});
|
||||
const data = await getAdminInvoices({});
|
||||
setInvoices(data.results || []);
|
||||
} catch (err: any) {
|
||||
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">
|
||||
Invoice #
|
||||
</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">
|
||||
Date
|
||||
</th>
|
||||
@@ -126,6 +129,9 @@ export default function AdminAllInvoicesPage() {
|
||||
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
|
||||
{invoice.invoice_number}
|
||||
</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">
|
||||
{new Date(invoice.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
|
||||
@@ -7,20 +7,12 @@ import { useState, useEffect } from 'react';
|
||||
import { Search, Filter, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { getAdminPayments, type Payment } from '../../services/billing.api';
|
||||
|
||||
interface Payment {
|
||||
id: number;
|
||||
account_name: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
payment_method: string;
|
||||
created_at: string;
|
||||
}
|
||||
type AdminPayment = Payment & { account_name?: string };
|
||||
|
||||
export default function AdminAllPaymentsPage() {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [payments, setPayments] = useState<AdminPayment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
@@ -32,7 +24,7 @@ export default function AdminAllPaymentsPage() {
|
||||
const loadPayments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchAPI('/v1/admin/payments/');
|
||||
const data = await getAdminPayments();
|
||||
setPayments(data.results || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load payments');
|
||||
@@ -45,6 +37,22 @@ export default function AdminAllPaymentsPage() {
|
||||
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) {
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="pending_approval">Pending Approval</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="succeeded">Succeeded</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="refunded">Refunded</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -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">
|
||||
<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>
|
||||
@@ -106,15 +119,15 @@ export default function AdminAllPaymentsPage() {
|
||||
filteredPayments.map((payment) => (
|
||||
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td className="px-6 py-4 font-medium">{payment.account_name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{payment.invoice_number || payment.invoice_id || '—'}
|
||||
</td>
|
||||
<td className="px-6 py-4 font-semibold">{payment.currency} {payment.amount}</td>
|
||||
<td className="px-6 py-4 text-sm">{payment.payment_method}</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={
|
||||
payment.status === 'succeeded' ? 'success' :
|
||||
payment.status === 'pending' ? 'warning' : 'error'
|
||||
}
|
||||
color={getStatusColor(payment.status)}
|
||||
>
|
||||
{payment.status}
|
||||
</Badge>
|
||||
|
||||
@@ -111,14 +111,16 @@ export interface Invoice {
|
||||
stripe_invoice_id?: string;
|
||||
billing_period_start?: string;
|
||||
billing_period_end?: string;
|
||||
account_name?: string;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: number;
|
||||
invoice_id: number;
|
||||
invoice_number?: string;
|
||||
amount: 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';
|
||||
created_at: string;
|
||||
processed_at?: string;
|
||||
@@ -186,7 +188,7 @@ export interface PendingPayment extends Payment {
|
||||
// ============================================================================
|
||||
|
||||
export async function getCreditBalance(): Promise<CreditBalance> {
|
||||
return fetchAPI('/v1/billing/credits/balance/balance/');
|
||||
return fetchAPI('/v1/billing/transactions/balance/');
|
||||
}
|
||||
|
||||
export async function getCreditTransactions(): Promise<{
|
||||
@@ -259,7 +261,33 @@ export async function getCreditUsageLimits(): Promise<{
|
||||
// ============================================================================
|
||||
|
||||
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?: {
|
||||
@@ -376,7 +404,7 @@ export async function getPayments(params?: {
|
||||
}
|
||||
|
||||
export async function submitManualPayment(data: {
|
||||
invoice_id: number;
|
||||
invoice_id?: number;
|
||||
payment_method: 'bank_transfer' | 'local_wallet' | 'manual';
|
||||
amount: string;
|
||||
currency?: string;
|
||||
@@ -409,9 +437,12 @@ export async function purchaseCreditPackage(data: {
|
||||
package_id: number;
|
||||
payment_method: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet';
|
||||
}): Promise<{
|
||||
id: number;
|
||||
status: string;
|
||||
invoice_id?: number;
|
||||
invoice_number?: string;
|
||||
total_amount?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
next_action?: string;
|
||||
stripe_client_secret?: string;
|
||||
paypal_order_id?: string;
|
||||
}> {
|
||||
@@ -467,6 +498,7 @@ export async function getAvailablePaymentMethods(): Promise<{
|
||||
}
|
||||
|
||||
export async function createManualPayment(data: {
|
||||
invoice_id?: number;
|
||||
amount: string;
|
||||
payment_method: string;
|
||||
reference: string;
|
||||
@@ -490,7 +522,7 @@ export async function getPendingPayments(): Promise<{
|
||||
results: PendingPayment[];
|
||||
count: number;
|
||||
}> {
|
||||
return fetchAPI('/v1/admin/payments/pending/');
|
||||
return fetchAPI('/v1/billing/admin/pending_payments/');
|
||||
}
|
||||
|
||||
export async function approvePayment(paymentId: number, data?: {
|
||||
@@ -499,7 +531,7 @@ export async function approvePayment(paymentId: number, data?: {
|
||||
message: string;
|
||||
payment: Payment;
|
||||
}> {
|
||||
return fetchAPI(`/v1/admin/payments/${paymentId}/approve/`, {
|
||||
return fetchAPI(`/v1/billing/admin/${paymentId}/approve_payment/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || {}),
|
||||
});
|
||||
@@ -512,7 +544,7 @@ export async function rejectPayment(paymentId: number, data: {
|
||||
message: string;
|
||||
payment: Payment;
|
||||
}> {
|
||||
return fetchAPI(`/v1/admin/payments/${paymentId}/reject/`, {
|
||||
return fetchAPI(`/v1/billing/admin/${paymentId}/reject_payment/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user