""" Billing Views - Payment confirmation and management """ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from django.db import transaction from django.utils import timezone from django.http import HttpResponse from datetime import timedelta from igny8_core.api.response import success_response, error_response, paginated_response from igny8_core.api.permissions import IsAdminOrOwner, IsAuthenticatedAndActive, HasTenantAccess from igny8_core.api.base import AccountModelViewSet from igny8_core.api.pagination import CustomPageNumberPagination from igny8_core.auth.models import Account, Subscription from igny8_core.business.billing.services.credit_service import CreditService from igny8_core.business.billing.services.invoice_service import InvoiceService from igny8_core.business.billing.models import CreditTransaction, Invoice, Payment, CreditPackage, AccountPaymentMethod import logging logger = logging.getLogger(__name__) class BillingViewSet(viewsets.GenericViewSet): """ ViewSet for billing operations (admin-only). """ permission_classes = [IsAdminOrOwner] @action(detail=False, methods=['post'], url_path='confirm-bank-transfer') def confirm_bank_transfer(self, request): """ Confirm a bank transfer payment and activate/renew subscription. Request body: { "account_id": 123, "external_payment_id": "BT-2025-001", "amount": "29.99", "payer_name": "John Doe", "proof_url": "https://...", "period_months": 1 } """ account_id = request.data.get('account_id') subscription_id = request.data.get('subscription_id') external_payment_id = request.data.get('external_payment_id') amount = request.data.get('amount') payer_name = request.data.get('payer_name') proof_url = request.data.get('proof_url') period_months = int(request.data.get('period_months', 1)) if not all([external_payment_id, amount, payer_name]): return error_response( error='external_payment_id, amount, and payer_name are required', status_code=status.HTTP_400_BAD_REQUEST, request=request ) if not account_id and not subscription_id: return error_response( error='Either account_id or subscription_id is required', status_code=status.HTTP_400_BAD_REQUEST, request=request ) try: with transaction.atomic(): # Get account if account_id: account = Account.objects.select_related('plan').get(id=account_id) subscription = getattr(account, 'subscription', None) else: subscription = Subscription.objects.select_related('account', 'account__plan').get(id=subscription_id) account = subscription.account if not account or not account.plan: return error_response( error='Account or plan not found', status_code=status.HTTP_404_NOT_FOUND, request=request ) # Calculate period dates based on billing cycle now = timezone.now() if account.plan.billing_cycle == 'monthly': period_end = now + timedelta(days=30 * period_months) elif account.plan.billing_cycle == 'annual': period_end = now + timedelta(days=365 * period_months) else: period_end = now + timedelta(days=30 * period_months) # Create or update subscription if not subscription: subscription = Subscription.objects.create( account=account, payment_method='bank_transfer', external_payment_id=external_payment_id, status='active', current_period_start=now, current_period_end=period_end, cancel_at_period_end=False ) else: subscription.payment_method = 'bank_transfer' subscription.external_payment_id = external_payment_id subscription.status = 'active' subscription.current_period_start = now subscription.current_period_end = period_end subscription.cancel_at_period_end = False subscription.save() # Update account account.payment_method = 'bank_transfer' account.status = 'active' monthly_credits = account.plan.get_effective_credits_per_month() account.credits = monthly_credits account.save() # Log transaction CreditTransaction.objects.create( account=account, transaction_type='subscription', amount=monthly_credits, balance_after=monthly_credits, description=f'Bank transfer payment confirmed: {external_payment_id}', metadata={ 'external_payment_id': external_payment_id, 'amount': str(amount), 'payer_name': payer_name, 'proof_url': proof_url if proof_url else '', 'period_months': period_months, 'confirmed_by': request.user.email } ) logger.info( f'Bank transfer confirmed for account {account.id}: ' f'{external_payment_id}, {amount}, {monthly_credits} credits added' ) return success_response( data={ 'account_id': account.id, 'subscription_id': subscription.id, 'status': 'active', 'credits': account.credits, 'period_start': subscription.current_period_start.isoformat(), 'period_end': subscription.current_period_end.isoformat() }, message='Bank transfer confirmed successfully', request=request ) except Account.DoesNotExist: return error_response( error='Account not found', status_code=status.HTTP_404_NOT_FOUND, request=request ) except Subscription.DoesNotExist: return error_response( error='Subscription not found', status_code=status.HTTP_404_NOT_FOUND, request=request ) except Exception as e: logger.error(f'Error confirming bank transfer: {str(e)}', exc_info=True) return error_response( error=f'Failed to confirm payment: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) class InvoiceViewSet(AccountModelViewSet): """ViewSet for user-facing invoices""" queryset = Invoice.objects.all().select_related('account') permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] pagination_class = CustomPageNumberPagination def get_queryset(self): """Filter invoices by account""" queryset = super().get_queryset() if hasattr(self.request, 'account') and self.request.account: queryset = queryset.filter(account=self.request.account) return queryset.order_by('-invoice_date', '-created_at') def list(self, request): """List invoices for current account""" queryset = self.get_queryset() # Filter by status if provided status_param = request.query_params.get('status') if status_param: queryset = queryset.filter(status=status_param) paginator = self.pagination_class() page = paginator.paginate_queryset(queryset, request) # Serialize invoice data results = [] for invoice in (page if page is not None else []): results.append({ 'id': invoice.id, 'invoice_number': invoice.invoice_number, 'status': invoice.status, 'total_amount': str(invoice.total), 'subtotal': str(invoice.subtotal), 'tax_amount': str(invoice.tax), 'currency': invoice.currency, 'invoice_date': invoice.invoice_date.isoformat(), 'due_date': invoice.due_date.isoformat(), 'paid_at': invoice.paid_at.isoformat() if invoice.paid_at else None, 'line_items': invoice.line_items, 'billing_email': invoice.billing_email, 'notes': invoice.notes, 'created_at': invoice.created_at.isoformat(), }) return paginated_response( {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, request=request ) def retrieve(self, request, pk=None): """Get invoice detail""" try: invoice = self.get_queryset().get(pk=pk) data = { 'id': invoice.id, 'invoice_number': invoice.invoice_number, 'status': invoice.status, 'total_amount': str(invoice.total), 'subtotal': str(invoice.subtotal), 'tax_amount': str(invoice.tax), 'currency': invoice.currency, 'invoice_date': invoice.invoice_date.isoformat(), 'due_date': invoice.due_date.isoformat(), 'paid_at': invoice.paid_at.isoformat() if invoice.paid_at else None, 'line_items': invoice.line_items, 'billing_email': invoice.billing_email, 'notes': invoice.notes, 'created_at': invoice.created_at.isoformat(), } return success_response(data=data, request=request) except Invoice.DoesNotExist: return error_response(error='Invoice not found', status_code=404, request=request) @action(detail=True, methods=['get']) def download_pdf(self, request, pk=None): """Download invoice PDF""" try: invoice = self.get_queryset().get(pk=pk) pdf_bytes = InvoiceService.generate_pdf(invoice) response = HttpResponse(pdf_bytes, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"' return response except Invoice.DoesNotExist: return error_response(error='Invoice not found', status_code=404, request=request) class PaymentViewSet(AccountModelViewSet): """ViewSet for user-facing payments""" queryset = Payment.objects.all().select_related('account', 'invoice') permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] pagination_class = CustomPageNumberPagination def get_queryset(self): """Filter payments by account""" queryset = super().get_queryset() if hasattr(self.request, 'account') and self.request.account: queryset = queryset.filter(account=self.request.account) return queryset.order_by('-created_at') def list(self, request): """List payments for current account""" queryset = self.get_queryset() # Filter by status if provided status_param = request.query_params.get('status') if status_param: queryset = queryset.filter(status=status_param) # Filter by invoice if provided invoice_id = request.query_params.get('invoice_id') if invoice_id: queryset = queryset.filter(invoice_id=invoice_id) paginator = self.pagination_class() page = paginator.paginate_queryset(queryset, request) # Serialize payment data results = [] for payment in (page if page is not None else []): results.append({ 'id': payment.id, 'invoice_id': payment.invoice_id, 'invoice_number': payment.invoice.invoice_number if payment.invoice else None, 'amount': str(payment.amount), 'currency': payment.currency, 'status': payment.status, 'payment_method': payment.payment_method, 'created_at': payment.created_at.isoformat(), 'processed_at': payment.processed_at.isoformat() if payment.processed_at else None, 'manual_reference': payment.manual_reference, 'manual_notes': payment.manual_notes, }) return paginated_response( {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, request=request ) @action(detail=False, methods=['post']) def manual(self, request): """Submit manual payment for approval""" invoice_id = request.data.get('invoice_id') amount = request.data.get('amount') payment_method = request.data.get('payment_method', 'bank_transfer') reference = request.data.get('reference', '') notes = request.data.get('notes', '') if not amount: return error_response(error='Amount is required', status_code=400, request=request) try: account = request.account invoice = None if invoice_id: invoice = Invoice.objects.get(id=invoice_id, account=account) payment = Payment.objects.create( account=account, invoice=invoice, amount=amount, currency='USD', payment_method=payment_method, status='pending_approval', manual_reference=reference, manual_notes=notes, ) return success_response( data={'id': payment.id, 'status': payment.status}, message='Manual payment submitted for approval', status_code=201, request=request ) except Invoice.DoesNotExist: return error_response(error='Invoice not found', status_code=404, request=request) class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet): """ViewSet for credit packages (read-only for users)""" queryset = CreditPackage.objects.filter(is_active=True).order_by('sort_order') permission_classes = [IsAuthenticatedAndActive] pagination_class = CustomPageNumberPagination def list(self, request): """List available credit packages""" queryset = self.get_queryset() paginator = self.pagination_class() page = paginator.paginate_queryset(queryset, request) results = [] for package in (page if page is not None else []): results.append({ 'id': package.id, 'name': package.name, 'slug': package.slug, 'credits': package.credits, 'price': str(package.price), 'discount_percentage': package.discount_percentage, 'is_featured': package.is_featured, 'description': package.description, 'display_order': package.sort_order, }) return paginated_response( {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, request=request ) class AccountPaymentMethodViewSet(AccountModelViewSet): """ViewSet for account payment methods""" queryset = AccountPaymentMethod.objects.all() permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] pagination_class = CustomPageNumberPagination def get_queryset(self): """Filter payment methods by account""" queryset = super().get_queryset() if hasattr(self.request, 'account') and self.request.account: queryset = queryset.filter(account=self.request.account) return queryset.order_by('-is_default', 'type') def list(self, request): """List payment methods for current account""" queryset = self.get_queryset() paginator = self.pagination_class() page = paginator.paginate_queryset(queryset, request) results = [] for method in (page if page is not None else []): results.append({ 'id': str(method.id), 'type': method.type, 'display_name': method.display_name, 'is_default': method.is_default, 'is_enabled': method.is_enabled if hasattr(method, 'is_enabled') else True, 'instructions': method.instructions, }) return paginated_response( {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, request=request )