""" 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 .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), }) 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() } })