style: implement custom color schemes and gradients for account section, enhancing visual hierarchy
910 lines
39 KiB
Python
910 lines
39 KiB
Python
"""
|
|
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 rest_framework.permissions import AllowAny
|
|
from django.db import transaction
|
|
from django.db.models import Q
|
|
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, PaymentMethodConfig
|
|
)
|
|
from igny8_core.modules.billing.serializers import PaymentMethodConfigSerializer, PaymentConfirmationSerializer
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BillingViewSet(viewsets.GenericViewSet):
|
|
"""
|
|
ViewSet for billing operations (admin-only).
|
|
"""
|
|
permission_classes = [IsAdminOrOwner]
|
|
|
|
def get_permissions(self):
|
|
"""
|
|
Allow action-level permissions to override class-level permissions.
|
|
"""
|
|
# Try to get permission_classes from the action
|
|
try:
|
|
# DRF stores action permission_classes in the view method
|
|
return [permission() for permission in self.permission_classes]
|
|
except Exception:
|
|
return super().get_permissions()
|
|
|
|
@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
|
|
)
|
|
|
|
@action(detail=False, methods=['get'], url_path='payment-methods', permission_classes=[AllowAny])
|
|
def list_payment_methods(self, request):
|
|
"""
|
|
Get available payment methods for a specific country.
|
|
Public endpoint - only returns enabled payment methods.
|
|
Does not expose sensitive configuration details.
|
|
|
|
Query params:
|
|
country: ISO 2-letter country code (default: 'US')
|
|
|
|
Returns payment methods filtered by country.
|
|
"""
|
|
country = request.GET.get('country', 'US').upper()
|
|
|
|
# Get country-specific methods
|
|
methods = PaymentMethodConfig.objects.filter(
|
|
country_code=country,
|
|
is_enabled=True
|
|
).order_by('sort_order')
|
|
|
|
# Serialize using the proper serializer
|
|
serializer = PaymentMethodConfigSerializer(methods, many=True)
|
|
|
|
# Return in consistent format
|
|
return Response({
|
|
'success': True,
|
|
'results': serializer.data
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=False, methods=['post'], url_path='payments/confirm', permission_classes=[IsAuthenticatedAndActive])
|
|
def confirm_payment(self, request):
|
|
"""
|
|
User confirms manual payment (bank transfer or local wallet).
|
|
Creates Payment record with status='pending_approval' for admin review.
|
|
|
|
Request body:
|
|
{
|
|
"invoice_id": 123,
|
|
"payment_method": "bank_transfer",
|
|
"manual_reference": "BT-20251208-12345",
|
|
"manual_notes": "Transferred via ABC Bank",
|
|
"amount": "29.00",
|
|
"proof_url": "https://..." // optional
|
|
}
|
|
"""
|
|
serializer = PaymentConfirmationSerializer(data=request.data)
|
|
if not serializer.is_valid():
|
|
return error_response(
|
|
error=serializer.errors,
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
invoice_id = serializer.validated_data['invoice_id']
|
|
payment_method = serializer.validated_data['payment_method']
|
|
manual_reference = serializer.validated_data['manual_reference']
|
|
manual_notes = serializer.validated_data.get('manual_notes', '')
|
|
amount = serializer.validated_data['amount']
|
|
proof_url = serializer.validated_data.get('proof_url')
|
|
|
|
try:
|
|
# Get invoice - must belong to user's account
|
|
invoice = Invoice.objects.select_related('account').get(
|
|
id=invoice_id,
|
|
account=request.account
|
|
)
|
|
|
|
# Check if payment already exists for this invoice
|
|
existing_payment = Payment.objects.filter(
|
|
invoice=invoice,
|
|
status__in=['pending_approval', 'succeeded']
|
|
).first()
|
|
|
|
if existing_payment:
|
|
if existing_payment.status == 'succeeded':
|
|
return error_response(
|
|
error='This invoice has already been paid and approved.',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
else:
|
|
return error_response(
|
|
error=f'A payment confirmation is already pending approval for this invoice (Payment ID: {existing_payment.id}).',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
# Validate amount matches invoice
|
|
if amount != invoice.total:
|
|
return error_response(
|
|
error=f'Amount mismatch. Invoice total is {invoice.total} {invoice.currency}',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
# Create payment record with pending approval status
|
|
payment = Payment.objects.create(
|
|
account=request.account,
|
|
invoice=invoice,
|
|
amount=amount,
|
|
currency=invoice.currency,
|
|
status='pending_approval',
|
|
payment_method=payment_method,
|
|
manual_reference=manual_reference,
|
|
manual_notes=manual_notes,
|
|
metadata={'proof_url': proof_url, 'submitted_by': request.user.email} if proof_url else {'submitted_by': request.user.email}
|
|
)
|
|
|
|
logger.info(
|
|
f'Payment confirmation submitted: Payment {payment.id}, '
|
|
f'Invoice {invoice.invoice_number}, Account {request.account.id}, '
|
|
f'Reference: {manual_reference}'
|
|
)
|
|
|
|
# Send email notification to user
|
|
try:
|
|
from igny8_core.business.billing.services.email_service import BillingEmailService
|
|
BillingEmailService.send_payment_confirmation_email(payment, request.account)
|
|
except Exception as e:
|
|
logger.error(f'Failed to send payment confirmation email: {str(e)}')
|
|
|
|
return success_response(
|
|
data={
|
|
'payment_id': payment.id,
|
|
'invoice_id': invoice.id,
|
|
'invoice_number': invoice.invoice_number,
|
|
'status': 'pending_approval',
|
|
'amount': str(amount),
|
|
'currency': invoice.currency,
|
|
'manual_reference': manual_reference
|
|
},
|
|
message='Payment confirmation submitted for review. You will be notified once approved.',
|
|
request=request
|
|
)
|
|
|
|
except Invoice.DoesNotExist:
|
|
return error_response(
|
|
error='Invoice not found. Please check the invoice ID or contact support.',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
except ValueError as ve:
|
|
return error_response(
|
|
error=f'Invalid amount format: {str(ve)}',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
logger.error(f'Error confirming payment: {str(e)}', exc_info=True)
|
|
return error_response(
|
|
error='An unexpected error occurred while processing your payment confirmation. Please try again or contact support.',
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['post'], url_path='approve', permission_classes=[IsAdminOrOwner])
|
|
def approve_payment(self, request, pk=None):
|
|
"""
|
|
Admin approves a manual payment.
|
|
Atomically updates: payment status → invoice paid → subscription active → account active → add credits.
|
|
|
|
Request body:
|
|
{
|
|
"admin_notes": "Verified payment in bank statement"
|
|
}
|
|
"""
|
|
admin_notes = request.data.get('admin_notes', '')
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
# Get payment with all related objects to prevent N+1 queries
|
|
payment = Payment.objects.select_related(
|
|
'invoice',
|
|
'invoice__subscription',
|
|
'invoice__subscription__plan',
|
|
'account',
|
|
'account__subscription',
|
|
'account__subscription__plan',
|
|
'account__plan'
|
|
).get(id=pk)
|
|
|
|
if payment.status != 'pending_approval':
|
|
status_msg = {
|
|
'succeeded': 'This payment has already been approved and processed',
|
|
'failed': 'This payment was previously rejected and cannot be approved',
|
|
'refunded': 'This payment was refunded and cannot be re-approved'
|
|
}.get(payment.status, f'Payment has invalid status: {payment.status}')
|
|
return error_response(
|
|
error=status_msg,
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
invoice = payment.invoice
|
|
account = payment.account
|
|
|
|
# Validate invoice is still pending
|
|
if invoice.status == 'paid':
|
|
return error_response(
|
|
error='Invoice is already marked as paid. Payment cannot be approved again.',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
# Validate invoice is not void
|
|
if invoice.status == 'void':
|
|
return error_response(
|
|
error='Invoice has been voided. Payment cannot be approved for a void invoice.',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
# Validate amount matches
|
|
if payment.amount != invoice.total:
|
|
return error_response(
|
|
error=f'Payment amount ({payment.currency} {payment.amount}) does not match invoice total ({invoice.currency} {invoice.total}). Please verify the payment.',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
# Get subscription from invoice first, fallback to account.subscription
|
|
subscription = None
|
|
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
|
|
subscription = invoice.subscription
|
|
elif account and hasattr(account, 'subscription'):
|
|
try:
|
|
subscription = account.subscription
|
|
except Exception:
|
|
pass
|
|
|
|
# 1. Update Payment
|
|
payment.status = 'succeeded'
|
|
payment.approved_by = request.user
|
|
payment.approved_at = timezone.now()
|
|
payment.processed_at = timezone.now()
|
|
payment.admin_notes = admin_notes
|
|
payment.save(update_fields=['status', 'approved_by', 'approved_at', 'processed_at', 'admin_notes'])
|
|
|
|
# 2. Update Invoice
|
|
invoice.status = 'paid'
|
|
invoice.paid_at = timezone.now()
|
|
invoice.save(update_fields=['status', 'paid_at'])
|
|
|
|
# 3. Update Subscription
|
|
if subscription:
|
|
subscription.status = 'active'
|
|
subscription.external_payment_id = payment.manual_reference
|
|
subscription.save(update_fields=['status', 'external_payment_id'])
|
|
|
|
# 4. Update Account
|
|
account.status = 'active'
|
|
account.save(update_fields=['status'])
|
|
|
|
# 5. Add Credits (if subscription has plan)
|
|
credits_added = 0
|
|
try:
|
|
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
|
credits_added = subscription.plan.included_credits
|
|
|
|
# Use CreditService to add credits
|
|
CreditService.add_credits(
|
|
account=account,
|
|
amount=credits_added,
|
|
transaction_type='subscription',
|
|
description=f'{subscription.plan.name} plan credits - Invoice {invoice.invoice_number}',
|
|
metadata={
|
|
'subscription_id': subscription.id,
|
|
'invoice_id': invoice.id,
|
|
'payment_id': payment.id,
|
|
'plan_id': subscription.plan.id,
|
|
'approved_by': request.user.email
|
|
}
|
|
)
|
|
elif account and account.plan and account.plan.included_credits > 0:
|
|
# Fallback: use account plan if subscription not found
|
|
credits_added = account.plan.included_credits
|
|
CreditService.add_credits(
|
|
account=account,
|
|
amount=credits_added,
|
|
transaction_type='subscription',
|
|
description=f'{account.plan.name} plan credits - Invoice {invoice.invoice_number}',
|
|
metadata={
|
|
'invoice_id': invoice.id,
|
|
'payment_id': payment.id,
|
|
'plan_id': account.plan.id,
|
|
'approved_by': request.user.email,
|
|
'fallback': 'account_plan'
|
|
}
|
|
)
|
|
except Exception as credit_error:
|
|
# Rollback payment approval if credit addition fails
|
|
logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True)
|
|
raise Exception(f'Failed to add credits: {str(credit_error)}') from credit_error
|
|
|
|
logger.info(
|
|
f'Payment approved: Payment {payment.id}, Invoice {invoice.invoice_number}, '
|
|
f'Account {account.id} activated, {credits_added} credits added'
|
|
)
|
|
|
|
# Send activation email to user
|
|
try:
|
|
from igny8_core.business.billing.services.email_service import BillingEmailService
|
|
BillingEmailService.send_payment_approved_email(payment, account, subscription)
|
|
except Exception as e:
|
|
logger.error(f'Failed to send payment approved email: {str(e)}')
|
|
|
|
return success_response(
|
|
data={
|
|
'payment_id': payment.id,
|
|
'invoice_id': invoice.id,
|
|
'invoice_number': invoice.invoice_number,
|
|
'account_id': account.id,
|
|
'account_status': account.status,
|
|
'subscription_status': subscription.status if subscription else None,
|
|
'credits_added': credits_added,
|
|
'total_credits': account.credits,
|
|
'approved_by': request.user.email,
|
|
'approved_at': payment.approved_at.isoformat()
|
|
},
|
|
message='Payment approved successfully. Account activated.',
|
|
request=request
|
|
)
|
|
|
|
except Payment.DoesNotExist:
|
|
return error_response(
|
|
error='Payment not found. The payment may have been deleted or the ID is incorrect.',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
logger.error(f'Error approving payment {pk}: {str(e)}', exc_info=True)
|
|
|
|
# Provide specific error messages
|
|
error_msg = str(e)
|
|
if 'credit' in error_msg.lower():
|
|
error_msg = 'Failed to add credits to account. Payment not approved. Please check the plan configuration.'
|
|
elif 'subscription' in error_msg.lower():
|
|
error_msg = 'Failed to activate subscription. Payment not approved. Please verify subscription exists.'
|
|
else:
|
|
error_msg = f'Payment approval failed: {error_msg}'
|
|
|
|
return error_response(
|
|
error=error_msg,
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['post'], url_path='reject', permission_classes=[IsAdminOrOwner])
|
|
def reject_payment(self, request, pk=None):
|
|
"""
|
|
Admin rejects a manual payment.
|
|
|
|
Request body:
|
|
{
|
|
"admin_notes": "Transaction reference not found in bank statement"
|
|
}
|
|
"""
|
|
admin_notes = request.data.get('admin_notes', 'Payment rejected by admin')
|
|
|
|
try:
|
|
payment = Payment.objects.get(id=pk)
|
|
|
|
if payment.status != 'pending_approval':
|
|
return error_response(
|
|
error=f'Payment is not pending approval (current status: {payment.status})',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
payment.status = 'failed'
|
|
payment.approved_by = request.user
|
|
payment.approved_at = timezone.now()
|
|
payment.failed_at = timezone.now()
|
|
payment.admin_notes = admin_notes
|
|
payment.failure_reason = admin_notes
|
|
payment.save(update_fields=['status', 'approved_by', 'approved_at', 'failed_at', 'admin_notes', 'failure_reason'])
|
|
|
|
# Update account status to allow retry
|
|
account = payment.account
|
|
if account.status != 'active':
|
|
account.status = 'pending_payment'
|
|
account.save(update_fields=['status'])
|
|
|
|
logger.info(f'Payment rejected: Payment {payment.id}, Reason: {admin_notes}')
|
|
|
|
# Send rejection email to user
|
|
try:
|
|
from igny8_core.business.billing.services.email_service import BillingEmailService
|
|
BillingEmailService.send_payment_rejected_email(payment, account, admin_notes)
|
|
except Exception as e:
|
|
logger.error(f'Failed to send payment rejected email: {str(e)}')
|
|
|
|
return success_response(
|
|
data={
|
|
'payment_id': payment.id,
|
|
'status': 'failed',
|
|
'rejected_by': request.user.email,
|
|
'rejected_at': payment.approved_at.isoformat()
|
|
},
|
|
message='Payment rejected.',
|
|
request=request
|
|
)
|
|
|
|
except Payment.DoesNotExist:
|
|
return error_response(
|
|
error='Payment not found. The payment may have been deleted or the ID is incorrect.',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
logger.error(f'Error rejecting payment {pk}: {str(e)}', exc_info=True)
|
|
return error_response(
|
|
error=f'Failed to reject payment. Please try again or contact technical support.',
|
|
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': str(invoice.total), # Alias for compatibility
|
|
'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': str(invoice.total), # Alias for compatibility
|
|
'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
|
|
throttle_scope = 'payment_confirmation'
|
|
|
|
def get_throttles(self):
|
|
"""Apply stricter throttling to manual payment submission"""
|
|
from rest_framework.throttling import UserRateThrottle
|
|
if self.action == 'manual':
|
|
# 5 payment submissions per hour per user
|
|
class PaymentSubmissionThrottle(UserRateThrottle):
|
|
rate = '5/hour'
|
|
return [PaymentSubmissionThrottle()]
|
|
return super().get_throttles()
|
|
|
|
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,
|
|
# admin_notes intentionally excluded - internal only
|
|
})
|
|
|
|
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
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# USAGE SUMMARY (Plan Limits) - User-facing endpoint
|
|
# ============================================================================
|
|
|
|
from rest_framework.decorators import api_view, permission_classes
|
|
|
|
@api_view(['GET'])
|
|
@permission_classes([IsAuthenticatedAndActive, HasTenantAccess])
|
|
def get_usage_summary(request):
|
|
"""
|
|
Get comprehensive usage summary for current account.
|
|
Includes hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images).
|
|
|
|
GET /api/v1/billing/usage-summary/
|
|
"""
|
|
try:
|
|
account = getattr(request, 'account', None)
|
|
if not account:
|
|
return error_response(
|
|
error='Account not found.',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
from igny8_core.business.billing.services.limit_service import LimitService
|
|
summary = LimitService.get_usage_summary(account)
|
|
|
|
return success_response(
|
|
data=summary,
|
|
message='Usage summary retrieved successfully.',
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
logger.error(f'Error getting usage summary: {str(e)}', exc_info=True)
|
|
return error_response(
|
|
error=f'Failed to retrieve usage summary: {str(e)}',
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|