649 lines
24 KiB
Python
649 lines
24 KiB
Python
"""
|
|
Billing API Views
|
|
Comprehensive billing endpoints for invoices, payments, credit packages
|
|
"""
|
|
from rest_framework import viewsets, status, serializers
|
|
from rest_framework.decorators import action
|
|
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 drf_spectacular.utils import extend_schema, extend_schema_view
|
|
|
|
from .models import (
|
|
Invoice,
|
|
Payment,
|
|
CreditPackage,
|
|
PaymentMethodConfig,
|
|
CreditTransaction,
|
|
AccountPaymentMethod,
|
|
)
|
|
from .services.invoice_service import InvoiceService
|
|
from .services.payment_service import PaymentService
|
|
|
|
|
|
class InvoiceViewSet(viewsets.ViewSet):
|
|
"""Invoice management endpoints"""
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def list(self, request):
|
|
"""List invoices for current account"""
|
|
account = request.user.account
|
|
status_filter = request.query_params.get('status')
|
|
|
|
invoices = InvoiceService.get_account_invoices(
|
|
account=account,
|
|
status=status_filter
|
|
)
|
|
|
|
return Response({
|
|
'results': [
|
|
{
|
|
'id': inv.id,
|
|
'invoice_number': inv.invoice_number,
|
|
'status': inv.status,
|
|
'total_amount': str(inv.total_amount),
|
|
'subtotal': str(inv.subtotal),
|
|
'tax_amount': str(inv.tax_amount),
|
|
'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,
|
|
'billing_period_start': inv.billing_period_start.isoformat() if inv.billing_period_start else None,
|
|
'billing_period_end': inv.billing_period_end.isoformat() if inv.billing_period_end else None
|
|
}
|
|
for inv in invoices
|
|
],
|
|
'count': len(invoices)
|
|
})
|
|
|
|
def retrieve(self, request, pk=None):
|
|
"""Get invoice details"""
|
|
account = request.user.account
|
|
invoice = get_object_or_404(Invoice, id=pk, account=account)
|
|
|
|
return Response({
|
|
'id': invoice.id,
|
|
'invoice_number': invoice.invoice_number,
|
|
'status': invoice.status,
|
|
'total_amount': str(invoice.total_amount),
|
|
'subtotal': str(invoice.subtotal),
|
|
'tax_amount': str(invoice.tax_amount),
|
|
'currency': invoice.currency,
|
|
'created_at': invoice.created_at.isoformat(),
|
|
'paid_at': invoice.paid_at.isoformat() if invoice.paid_at else None,
|
|
'due_date': invoice.due_date.isoformat() if invoice.due_date else None,
|
|
'line_items': invoice.line_items,
|
|
'billing_email': invoice.billing_email,
|
|
'notes': invoice.notes,
|
|
'stripe_invoice_id': invoice.stripe_invoice_id,
|
|
'billing_period_start': invoice.billing_period_start.isoformat() if invoice.billing_period_start else None,
|
|
'billing_period_end': invoice.billing_period_end.isoformat() if invoice.billing_period_end else None
|
|
})
|
|
|
|
@action(detail=True, methods=['get'])
|
|
def download_pdf(self, request, pk=None):
|
|
"""Download invoice as PDF"""
|
|
account = request.user.account
|
|
invoice = get_object_or_404(Invoice, id=pk, account=account)
|
|
|
|
pdf_data = InvoiceService.generate_pdf(invoice)
|
|
|
|
response = HttpResponse(pdf_data, content_type='application/pdf')
|
|
response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"'
|
|
return response
|
|
|
|
|
|
class PaymentViewSet(viewsets.ViewSet):
|
|
"""Payment processing endpoints"""
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def list(self, request):
|
|
"""List payments for current account"""
|
|
account = request.user.account
|
|
status_filter = request.query_params.get('status')
|
|
|
|
payments = PaymentService.get_account_payments(
|
|
account=account,
|
|
status=status_filter
|
|
)
|
|
|
|
return Response({
|
|
'results': [
|
|
{
|
|
'id': pay.id,
|
|
'amount': str(pay.amount),
|
|
'currency': pay.currency,
|
|
'payment_method': pay.payment_method,
|
|
'status': pay.status,
|
|
'created_at': pay.created_at.isoformat(),
|
|
'processed_at': pay.processed_at.isoformat() if pay.processed_at else None,
|
|
'invoice_id': pay.invoice_id,
|
|
'invoice_number': pay.invoice.invoice_number if pay.invoice else None,
|
|
'transaction_reference': pay.transaction_reference,
|
|
'failure_reason': pay.failure_reason
|
|
}
|
|
for pay in payments
|
|
],
|
|
'count': len(payments)
|
|
})
|
|
|
|
@action(detail=False, methods=['get'])
|
|
def available_methods(self, request):
|
|
"""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({
|
|
'results': method_list,
|
|
'count': len(method_list),
|
|
**methods
|
|
})
|
|
|
|
@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') or request.data.get('reference')
|
|
notes = request.data.get('notes')
|
|
|
|
if not all([invoice_id, payment_method, transaction_reference]):
|
|
return Response(
|
|
{'error': 'Missing required fields'},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
invoice = get_object_or_404(Invoice, id=invoice_id, account=account)
|
|
|
|
if invoice.status == 'paid':
|
|
return Response(
|
|
{'error': 'Invoice already paid'},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
payment = PaymentService.create_manual_payment(
|
|
invoice=invoice,
|
|
payment_method=payment_method,
|
|
transaction_reference=transaction_reference,
|
|
admin_notes=notes
|
|
)
|
|
|
|
return Response({
|
|
'id': payment.id,
|
|
'status': payment.status,
|
|
'message': 'Payment submitted for approval. You will be notified once it is reviewed.'
|
|
}, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class AccountPaymentMethodSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = AccountPaymentMethod
|
|
fields = [
|
|
'id',
|
|
'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']
|
|
|
|
|
|
class AccountPaymentMethodViewSet(viewsets.ModelViewSet):
|
|
"""
|
|
CRUD for account-scoped payment methods (Stripe/PayPal/manual bank/local_wallet).
|
|
"""
|
|
serializer_class = AccountPaymentMethodSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
account = getattr(self.request.user, 'account', None)
|
|
qs = AccountPaymentMethod.objects.all()
|
|
if account:
|
|
qs = qs.filter(account=account)
|
|
else:
|
|
qs = qs.none()
|
|
return qs.order_by('-is_default', 'display_name', 'id')
|
|
|
|
def perform_create(self, serializer):
|
|
account = self.request.user.account
|
|
with models.transaction.atomic():
|
|
obj = serializer.save(account=account)
|
|
make_default = serializer.validated_data.get('is_default') or not AccountPaymentMethod.objects.filter(account=account, is_default=True).exists()
|
|
if make_default:
|
|
AccountPaymentMethod.objects.filter(account=account).exclude(id=obj.id).update(is_default=False)
|
|
obj.is_default = True
|
|
obj.save(update_fields=['is_default'])
|
|
|
|
def perform_update(self, serializer):
|
|
account = self.request.user.account
|
|
with models.transaction.atomic():
|
|
obj = serializer.save()
|
|
if serializer.validated_data.get('is_default'):
|
|
AccountPaymentMethod.objects.filter(account=account).exclude(id=obj.id).update(is_default=False)
|
|
|
|
@action(detail=True, methods=['post'])
|
|
def set_default(self, request, pk=None):
|
|
account = request.user.account
|
|
method = get_object_or_404(AccountPaymentMethod, id=pk, account=account)
|
|
with models.transaction.atomic():
|
|
AccountPaymentMethod.objects.filter(account=account).update(is_default=False)
|
|
method.is_default = True
|
|
method.save(update_fields=['is_default'])
|
|
return Response({'message': 'Default payment method updated', 'id': method.id})
|
|
|
|
|
|
class CreditPackageViewSet(viewsets.ViewSet):
|
|
"""Credit package endpoints"""
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def list(self, request):
|
|
"""List available credit packages"""
|
|
packages = CreditPackage.objects.filter(is_active=True).order_by('price')
|
|
|
|
return Response({
|
|
'results': [
|
|
{
|
|
'id': pkg.id,
|
|
'name': pkg.name,
|
|
'slug': pkg.slug,
|
|
'credits': pkg.credits,
|
|
'price': str(pkg.price),
|
|
'discount_percentage': pkg.discount_percentage,
|
|
'is_featured': pkg.is_featured,
|
|
'description': pkg.description,
|
|
'display_order': pkg.sort_order
|
|
}
|
|
for pkg in packages
|
|
],
|
|
'count': packages.count()
|
|
})
|
|
|
|
@action(detail=True, methods=['post'])
|
|
def purchase(self, request, pk=None):
|
|
"""Purchase a credit package"""
|
|
account = request.user.account
|
|
package = get_object_or_404(CreditPackage, id=pk, is_active=True)
|
|
payment_method = request.data.get('payment_method', 'stripe')
|
|
|
|
# Create invoice for credit package
|
|
invoice = InvoiceService.create_credit_package_invoice(
|
|
account=account,
|
|
credit_package=package
|
|
)
|
|
|
|
# Store credit package info in metadata
|
|
metadata = {
|
|
'credit_package_id': package.id,
|
|
'credit_amount': package.credits
|
|
}
|
|
|
|
if payment_method == 'stripe':
|
|
# TODO: Create Stripe payment intent
|
|
return Response({
|
|
'invoice_id': invoice.id,
|
|
'message': 'Stripe integration pending',
|
|
'next_action': 'redirect_to_stripe_checkout'
|
|
})
|
|
elif payment_method == 'paypal':
|
|
# TODO: Create PayPal order
|
|
return Response({
|
|
'invoice_id': invoice.id,
|
|
'message': 'PayPal integration pending',
|
|
'next_action': 'redirect_to_paypal_checkout'
|
|
})
|
|
else:
|
|
# Manual payment
|
|
return Response({
|
|
'invoice_id': invoice.id,
|
|
'invoice_number': invoice.invoice_number,
|
|
'total_amount': str(invoice.total_amount),
|
|
'message': 'Invoice created. Please submit payment details.',
|
|
'next_action': 'submit_manual_payment'
|
|
})
|
|
|
|
|
|
class CreditTransactionViewSet(viewsets.ViewSet):
|
|
"""Credit transaction history"""
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def list(self, request):
|
|
"""List credit transactions for current account"""
|
|
account = request.user.account
|
|
transactions = CreditTransaction.objects.filter(
|
|
account=account
|
|
).order_by('-created_at')[:100]
|
|
|
|
return Response({
|
|
'results': [
|
|
{
|
|
'id': txn.id,
|
|
'amount': txn.amount,
|
|
'transaction_type': txn.transaction_type,
|
|
'description': txn.description,
|
|
'created_at': txn.created_at.isoformat(),
|
|
'reference_id': txn.reference_id,
|
|
'metadata': txn.metadata
|
|
}
|
|
for txn in transactions
|
|
],
|
|
'count': transactions.count(),
|
|
'current_balance': account.credits
|
|
})
|
|
|
|
@action(detail=False, methods=['get'])
|
|
def balance(self, request):
|
|
"""Get current credit balance"""
|
|
account = request.user.account
|
|
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({
|
|
'credits': account.credits,
|
|
'plan_credits_per_month': included,
|
|
'credits_used_this_month': used_this_month,
|
|
'credits_remaining': max(account.credits, 0),
|
|
})
|
|
|
|
|
|
@extend_schema_view(
|
|
invoices=extend_schema(tags=['Admin Billing']),
|
|
payments=extend_schema(tags=['Admin Billing']),
|
|
pending_payments=extend_schema(tags=['Admin Billing']),
|
|
approve_payment=extend_schema(tags=['Admin Billing']),
|
|
reject_payment=extend_schema(tags=['Admin Billing']),
|
|
stats=extend_schema(tags=['Admin Billing']),
|
|
)
|
|
class AdminBillingViewSet(viewsets.ViewSet):
|
|
"""Admin billing management"""
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
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()
|
|
|
|
return Response({
|
|
'results': [
|
|
{
|
|
'id': pay.id,
|
|
'account_name': pay.account.name,
|
|
'amount': str(pay.amount),
|
|
'currency': pay.currency,
|
|
'payment_method': pay.payment_method,
|
|
'transaction_reference': pay.transaction_reference,
|
|
'created_at': pay.created_at.isoformat(),
|
|
'invoice_number': pay.invoice.invoice_number if pay.invoice else None,
|
|
'admin_notes': pay.admin_notes
|
|
}
|
|
for pay in payments
|
|
],
|
|
'count': len(payments)
|
|
})
|
|
|
|
@action(detail=True, methods=['post'])
|
|
def approve_payment(self, request, pk=None):
|
|
"""Approve a manual payment"""
|
|
error = self._require_admin(request)
|
|
if error:
|
|
return error
|
|
|
|
payment = get_object_or_404(Payment, id=pk)
|
|
admin_notes = request.data.get('notes')
|
|
|
|
try:
|
|
payment = PaymentService.approve_manual_payment(
|
|
payment=payment,
|
|
approved_by_user_id=request.user.id,
|
|
admin_notes=admin_notes
|
|
)
|
|
|
|
return Response({
|
|
'id': payment.id,
|
|
'status': payment.status,
|
|
'message': 'Payment approved successfully'
|
|
})
|
|
except ValueError as e:
|
|
return Response(
|
|
{'error': str(e)},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
@action(detail=True, methods=['post'])
|
|
def reject_payment(self, request, pk=None):
|
|
"""Reject a manual payment"""
|
|
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')
|
|
|
|
try:
|
|
payment = PaymentService.reject_manual_payment(
|
|
payment=payment,
|
|
rejected_by_user_id=request.user.id,
|
|
rejection_reason=rejection_reason
|
|
)
|
|
|
|
return Response({
|
|
'id': payment.id,
|
|
'status': payment.status,
|
|
'message': 'Payment rejected'
|
|
})
|
|
except ValueError as e:
|
|
return Response(
|
|
{'error': str(e)},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
@action(detail=False, methods=['get'])
|
|
def stats(self, request):
|
|
"""System billing stats"""
|
|
error = self._require_admin(request)
|
|
if error:
|
|
return error
|
|
|
|
from django.db.models import Sum, Count
|
|
from ...auth.models import Account
|
|
from datetime import datetime, timedelta
|
|
from django.utils import timezone
|
|
|
|
# Date ranges
|
|
now = timezone.now()
|
|
this_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
last_30_days = now - timedelta(days=30)
|
|
|
|
# Account stats
|
|
total_accounts = Account.objects.count()
|
|
active_accounts = Account.objects.filter(status='active').count()
|
|
new_accounts_this_month = Account.objects.filter(
|
|
created_at__gte=this_month_start
|
|
).count()
|
|
|
|
# Subscription stats
|
|
# Subscriptions are linked via OneToOne "subscription"
|
|
active_subscriptions = Account.objects.filter(
|
|
subscription__status='active'
|
|
).distinct().count()
|
|
|
|
# Revenue stats
|
|
total_revenue = Payment.objects.filter(
|
|
status__in=['completed', 'succeeded'],
|
|
amount__gt=0
|
|
).aggregate(total=Sum('amount'))['total'] or 0
|
|
|
|
revenue_this_month = Payment.objects.filter(
|
|
status__in=['completed', 'succeeded'],
|
|
processed_at__gte=this_month_start,
|
|
amount__gt=0
|
|
).aggregate(total=Sum('amount'))['total'] or 0
|
|
|
|
# Credit stats
|
|
credits_issued = CreditTransaction.objects.filter(
|
|
transaction_type='purchase',
|
|
created_at__gte=last_30_days
|
|
).aggregate(total=Sum('amount'))['total'] or 0
|
|
|
|
# Usage transactions are stored as deductions (negative amounts)
|
|
credits_used = abs(CreditTransaction.objects.filter(
|
|
created_at__gte=last_30_days,
|
|
amount__lt=0
|
|
).aggregate(total=Sum('amount'))['total'] or 0)
|
|
|
|
# Payment/Invoice stats
|
|
pending_approvals = Payment.objects.filter(status='pending_approval').count()
|
|
|
|
invoices_pending = Invoice.objects.filter(status='pending').count()
|
|
invoices_overdue = Invoice.objects.filter(
|
|
status='pending',
|
|
due_date__lt=now
|
|
).count()
|
|
|
|
# Recent activity
|
|
recent_payments = Payment.objects.filter(
|
|
status__in=['completed', 'succeeded']
|
|
).order_by('-processed_at')[:5]
|
|
|
|
recent_activity = []
|
|
for pay in recent_payments:
|
|
account_name = getattr(pay.account, 'name', 'Unknown')
|
|
currency = pay.currency or 'USD'
|
|
ts = pay.processed_at.isoformat() if pay.processed_at else now.isoformat()
|
|
recent_activity.append({
|
|
'id': pay.id,
|
|
'type': 'payment',
|
|
'account_name': account_name,
|
|
'amount': str(pay.amount),
|
|
'currency': currency,
|
|
'timestamp': ts,
|
|
'description': f'Payment received via {pay.payment_method or "unknown"}'
|
|
})
|
|
|
|
return Response({
|
|
'total_accounts': total_accounts,
|
|
'active_accounts': active_accounts,
|
|
'new_accounts_this_month': new_accounts_this_month,
|
|
'active_subscriptions': active_subscriptions,
|
|
'total_revenue': str(total_revenue),
|
|
'revenue_this_month': str(revenue_this_month),
|
|
'credits_issued_30d': credits_issued,
|
|
'credits_used_30d': credits_used,
|
|
'pending_approvals': pending_approvals,
|
|
'invoices_pending': invoices_pending,
|
|
'invoices_overdue': invoices_overdue,
|
|
'recent_activity': recent_activity,
|
|
'system_health': {
|
|
'status': 'operational',
|
|
'last_check': now.isoformat()
|
|
}
|
|
})
|