This commit is contained in:
IGNY8 VPS (Salman)
2025-12-08 07:11:06 +00:00
parent 7483de6aba
commit d144f5d19a
13 changed files with 2209 additions and 842 deletions

View File

@@ -109,9 +109,11 @@ class APIKeyAuthentication(BaseAuthentication):
try:
from igny8_core.auth.models import Site, User
from igny8_core.auth.utils import validate_account_and_plan
from rest_framework.exceptions import AuthenticationFailed
# Find site by API key
site = Site.objects.select_related('account', 'account__owner').filter(
site = Site.objects.select_related('account', 'account__owner', 'account__plan').filter(
wp_api_key=api_key,
is_active=True
).first()
@@ -119,8 +121,17 @@ class APIKeyAuthentication(BaseAuthentication):
if not site:
return None # API key not found or site inactive
# Get account and user (prefer owner but gracefully fall back)
# Get account and validate it
account = site.account
if not account:
raise AuthenticationFailed('No account associated with this API key.')
# CRITICAL FIX: Validate account and plan status
is_valid, error_message, http_status = validate_account_and_plan(account)
if not is_valid:
raise AuthenticationFailed(error_message)
# Get user (prefer owner but gracefully fall back)
user = account.owner
if not user or not getattr(user, 'is_active', False):
# Fall back to any active developer/owner/admin in the account

View File

@@ -21,14 +21,9 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
def allow_request(self, request, view):
"""
Check if request should be throttled
Bypasses throttling if:
- DEBUG mode is True
- IGNY8_DEBUG_THROTTLE environment variable is True
- User belongs to aws-admin or other system accounts
- User is admin/developer role
- Public blueprint list request with site filter (for Sites Renderer)
Check if request should be throttled.
Only bypasses for DEBUG mode or public requests.
Enforces per-account throttling for all authenticated users.
"""
# Check if throttling should be bypassed
debug_bypass = getattr(settings, 'DEBUG', False)
@@ -41,12 +36,7 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated:
public_blueprint_bypass = True
# Bypass for authenticated users (avoid user-facing 429s)
authenticated_bypass = False
if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
authenticated_bypass = True # Do not throttle logged-in users
if debug_bypass or env_bypass or public_blueprint_bypass or authenticated_bypass:
if debug_bypass or env_bypass or public_blueprint_bypass:
# In debug mode or for system accounts, still set throttle headers but don't actually throttle
# This allows testing throttle headers without blocking requests
if hasattr(self, 'get_rate'):
@@ -67,9 +57,27 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
}
return True
# Normal throttling behavior
# Normal throttling with per-account keying
return super().allow_request(request, view)
def get_cache_key(self, request, view):
"""
Override to add account-based throttle keying.
Keys by (scope, account.id) instead of just user.
"""
if not self.scope:
return None
# Get account from request
account = getattr(request, 'account', None)
if not account and hasattr(request, 'user') and request.user and request.user.is_authenticated:
account = getattr(request.user, 'account', None)
account_id = account.id if account else 'anon'
# Build throttle key: scope:account_id
return f'{self.scope}:{account_id}'
def get_rate(self):
"""
Get rate for the current scope

View File

@@ -132,27 +132,14 @@ class AccountContextMiddleware(MiddlewareMixin):
def _validate_account_and_plan(self, request, user):
"""
Ensure the authenticated user has an account and an active plan.
If not, logout the user (for session auth) and block the request.
Uses shared validation helper for consistency.
"""
try:
account = getattr(user, 'account', None)
except Exception:
account = None
from .utils import validate_account_and_plan
if not account:
return self._deny_request(
request,
error='Account not configured for this user. Please contact support.',
status_code=status.HTTP_403_FORBIDDEN,
)
is_valid, error_message, http_status = validate_account_and_plan(user)
plan = getattr(account, 'plan', None)
if plan is None or getattr(plan, 'is_active', False) is False:
return self._deny_request(
request,
error='Active subscription required. Visit igny8.com/pricing to subscribe.',
status_code=status.HTTP_402_PAYMENT_REQUIRED,
)
if not is_valid:
return self._deny_request(request, error_message, http_status)
return None

View File

@@ -0,0 +1,105 @@
# Generated manually based on FINAL-IMPLEMENTATION-REQUIREMENTS.md
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0006_soft_delete_and_retention'),
]
operations = [
# Add payment_method to Account
migrations.AddField(
model_name='account',
name='payment_method',
field=models.CharField(
max_length=30,
choices=[
('stripe', 'Stripe'),
('paypal', 'PayPal'),
('bank_transfer', 'Bank Transfer'),
],
default='stripe',
help_text='Payment method used for this account'
),
),
# Add payment_method to Subscription
migrations.AddField(
model_name='subscription',
name='payment_method',
field=models.CharField(
max_length=30,
choices=[
('stripe', 'Stripe'),
('paypal', 'PayPal'),
('bank_transfer', 'Bank Transfer'),
],
default='stripe',
help_text='Payment method for this subscription'
),
),
# Add external_payment_id to Subscription
migrations.AddField(
model_name='subscription',
name='external_payment_id',
field=models.CharField(
max_length=255,
blank=True,
null=True,
help_text='External payment reference (bank transfer ref, PayPal transaction ID)'
),
),
# Make stripe_subscription_id nullable
migrations.AlterField(
model_name='subscription',
name='stripe_subscription_id',
field=models.CharField(
max_length=255,
blank=True,
null=True,
db_index=True,
help_text='Stripe subscription ID (when using Stripe)'
),
),
# Add pending_payment status to Account
migrations.AlterField(
model_name='account',
name='status',
field=models.CharField(
max_length=20,
choices=[
('active', 'Active'),
('suspended', 'Suspended'),
('trial', 'Trial'),
('cancelled', 'Cancelled'),
('pending_payment', 'Pending Payment'),
],
default='trial'
),
),
# Add pending_payment status to Subscription
migrations.AlterField(
model_name='subscription',
name='status',
field=models.CharField(
max_length=20,
choices=[
('active', 'Active'),
('past_due', 'Past Due'),
('canceled', 'Canceled'),
('trialing', 'Trialing'),
('pending_payment', 'Pending Payment'),
]
),
),
# Add index on payment_method
migrations.AddIndex(
model_name='account',
index=models.Index(fields=['payment_method'], name='auth_acc_payment_idx'),
),
migrations.AddIndex(
model_name='subscription',
index=models.Index(fields=['payment_method'], name='auth_sub_payment_idx'),
),
]

View File

@@ -62,6 +62,13 @@ class Account(SoftDeletableModel):
('suspended', 'Suspended'),
('trial', 'Trial'),
('cancelled', 'Cancelled'),
('pending_payment', 'Pending Payment'),
]
PAYMENT_METHOD_CHOICES = [
('stripe', 'Stripe'),
('paypal', 'PayPal'),
('bank_transfer', 'Bank Transfer'),
]
name = models.CharField(max_length=255)
@@ -77,6 +84,12 @@ class Account(SoftDeletableModel):
plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts')
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)])
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
payment_method = models.CharField(
max_length=30,
choices=PAYMENT_METHOD_CHOICES,
default='stripe',
help_text='Payment method used for this account'
)
deletion_retention_days = models.PositiveIntegerField(
default=14,
validators=[MinValueValidator(1), MaxValueValidator(365)],
@@ -191,17 +204,42 @@ class Plan(models.Model):
class Subscription(models.Model):
"""
Account subscription model linking to Stripe.
Account subscription model supporting multiple payment methods.
"""
STATUS_CHOICES = [
('active', 'Active'),
('past_due', 'Past Due'),
('canceled', 'Canceled'),
('trialing', 'Trialing'),
('pending_payment', 'Pending Payment'),
]
PAYMENT_METHOD_CHOICES = [
('stripe', 'Stripe'),
('paypal', 'PayPal'),
('bank_transfer', 'Bank Transfer'),
]
account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id')
stripe_subscription_id = models.CharField(max_length=255, unique=True)
stripe_subscription_id = models.CharField(
max_length=255,
blank=True,
null=True,
db_index=True,
help_text='Stripe subscription ID (when using Stripe)'
)
payment_method = models.CharField(
max_length=30,
choices=PAYMENT_METHOD_CHOICES,
default='stripe',
help_text='Payment method for this subscription'
)
external_payment_id = models.CharField(
max_length=255,
blank=True,
null=True,
help_text='External payment reference (bank transfer ref, PayPal transaction ID)'
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES)
current_period_start = models.DateTimeField()
current_period_end = models.DateTimeField()

View File

@@ -27,8 +27,8 @@ class SubscriptionSerializer(serializers.ModelSerializer):
model = Subscription
fields = [
'id', 'account', 'account_name', 'account_slug',
'stripe_subscription_id', 'status',
'current_period_start', 'current_period_end',
'stripe_subscription_id', 'payment_method', 'external_payment_id',
'status', 'current_period_start', 'current_period_end',
'cancel_at_period_end',
'created_at', 'updated_at'
]
@@ -48,7 +48,11 @@ class AccountSerializer(serializers.ModelSerializer):
class Meta:
model = Account
fields = ['id', 'name', 'slug', 'owner', 'plan', 'plan_id', 'credits', 'status', 'subscription', 'created_at']
fields = [
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
'credits', 'status', 'payment_method',
'subscription', 'created_at'
]
read_only_fields = ['owner', 'created_at']
@@ -260,6 +264,12 @@ class RegisterSerializer(serializers.Serializer):
allow_null=True,
default=None
)
plan_slug = serializers.CharField(max_length=50, required=False)
payment_method = serializers.ChoiceField(
choices=['stripe', 'paypal', 'bank_transfer'],
default='bank_transfer',
required=False
)
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:

View File

@@ -128,3 +128,65 @@ def get_token_expiry(token_type='access'):
return now + get_refresh_token_expiry()
return now + get_access_token_expiry()
def validate_account_and_plan(user_or_account):
"""
Validate account exists and has active plan.
Allows trial, active, and pending_payment statuses.
Args:
user_or_account: User or Account instance
Returns:
tuple: (is_valid: bool, error_msg: str or None, http_status: int or None)
"""
from rest_framework import status
from .models import User, Account
# Extract account from user or use directly
if isinstance(user_or_account, User):
try:
account = getattr(user_or_account, 'account', None)
except Exception:
account = None
elif isinstance(user_or_account, Account):
account = user_or_account
else:
return (False, 'Invalid object type', status.HTTP_400_BAD_REQUEST)
# Check account exists
if not account:
return (
False,
'Account not configured for this user. Please contact support.',
status.HTTP_403_FORBIDDEN
)
# Check account status - allow trial, active, pending_payment
# Block only suspended and cancelled
if hasattr(account, 'status') and account.status in ['suspended', 'cancelled']:
return (
False,
f'Account is {account.status}. Please contact support.',
status.HTTP_403_FORBIDDEN
)
# Check plan exists and is active
plan = getattr(account, 'plan', None)
if not plan:
return (
False,
'No subscription plan assigned. Visit igny8.com/pricing to subscribe.',
status.HTTP_402_PAYMENT_REQUIRED
)
if hasattr(plan, 'is_active') and not plan.is_active:
return (
False,
'Active subscription required. Visit igny8.com/pricing to subscribe.',
status.HTTP_402_PAYMENT_REQUIRED
)
return (True, None, None)

View File

@@ -10,6 +10,7 @@ from .views import (
CreditTransactionViewSet,
AdminBillingViewSet,
AccountPaymentMethodViewSet,
BillingViewSet,
)
from igny8_core.modules.billing.views import (
CreditBalanceViewSet,
@@ -22,6 +23,7 @@ router.register(r'payments', PaymentViewSet, basename='payment')
router.register(r'credit-packages', CreditPackageViewSet, basename='credit-package')
router.register(r'transactions', CreditTransactionViewSet, basename='transaction')
router.register(r'payment-methods', AccountPaymentMethodViewSet, basename='payment-method')
router.register(r'admin', BillingViewSet, basename='billing-admin')
# Canonical credits endpoints (unified billing)
router.register(r'credits/balance', CreditBalanceViewSet, basename='credit-balance')
router.register(r'credits/usage', CreditUsageViewSet, basename='credit-usage')

View File

@@ -1,821 +1,168 @@
"""
Billing API Views
Comprehensive billing endpoints for invoices, payments, credit packages
Billing Views - Payment confirmation and management
"""
from rest_framework import viewsets, status, serializers
from rest_framework import viewsets, status
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 django.db import transaction
from django.utils import timezone
from datetime import timedelta
from igny8_core.api.response import success_response, error_response
from igny8_core.api.permissions import IsAdminOrOwner
from igny8_core.auth.models import Account, Subscription
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.business.billing.models import CreditTransaction
import logging
from .models import (
Invoice,
Payment,
CreditPackage,
PaymentMethodConfig,
CreditTransaction,
AccountPaymentMethod,
)
from .services.invoice_service import InvoiceService
from .services.payment_service import PaymentService
logger = logging.getLogger(__name__)
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):
class BillingViewSet(viewsets.GenericViewSet):
"""
CRUD for account-scoped payment methods (Stripe/PayPal/manual bank/local_wallet).
ViewSet for billing operations (admin-only).
"""
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]
permission_classes = [IsAdminOrOwner]
def list(self, request):
"""List available credit packages"""
packages = CreditPackage.objects.filter(is_active=True).order_by('price')
@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.
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
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 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
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
)
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')
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:
payment = PaymentService.approve_manual_payment(
payment=payment,
approved_by_user_id=request.user.id,
admin_notes=admin_notes
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
)
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
except Subscription.DoesNotExist:
return error_response(
error='Subscription not found',
status_code=status.HTTP_404_NOT_FOUND,
request=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
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
)
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', 'post'])
def payment_method_configs(self, request):
"""List/create payment method configs (country-level)"""
error = self._require_admin(request)
if error:
return error
class PMConfigSerializer(serializers.ModelSerializer):
class Meta:
model = PaymentMethodConfig
fields = [
'id',
'country_code',
'payment_method',
'display_name',
'is_enabled',
'instructions',
'sort_order',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
if request.method.lower() == 'post':
serializer = PMConfigSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
return Response(PMConfigSerializer(obj).data, status=status.HTTP_201_CREATED)
qs = PaymentMethodConfig.objects.all().order_by('country_code', 'sort_order', 'payment_method')
country = request.query_params.get('country_code')
method = request.query_params.get('payment_method')
if country:
qs = qs.filter(country_code=country)
if method:
qs = qs.filter(payment_method=method)
data = PMConfigSerializer(qs, many=True).data
return Response({'results': data, 'count': len(data)})
@action(detail=True, methods=['get', 'patch', 'put', 'delete'], url_path='payment_method_config')
@extend_schema(tags=['Admin Billing'])
def payment_method_config(self, request, pk=None):
"""Retrieve/update/delete a payment method config"""
error = self._require_admin(request)
if error:
return error
obj = get_object_or_404(PaymentMethodConfig, id=pk)
class PMConfigSerializer(serializers.ModelSerializer):
class Meta:
model = PaymentMethodConfig
fields = [
'id',
'country_code',
'payment_method',
'display_name',
'is_enabled',
'instructions',
'sort_order',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
if request.method.lower() == 'get':
return Response(PMConfigSerializer(obj).data)
if request.method.lower() in ['patch', 'put']:
partial = request.method.lower() == 'patch'
serializer = PMConfigSerializer(obj, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
return Response(PMConfigSerializer(obj).data)
# delete
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=['get', 'post'])
@extend_schema(tags=['Admin Billing'])
def account_payment_methods(self, request):
"""List/create account payment methods (admin)"""
error = self._require_admin(request)
if error:
return error
class AccountPMSerializer(serializers.ModelSerializer):
class Meta:
model = AccountPaymentMethod
fields = [
'id',
'account',
'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']
if request.method.lower() == 'post':
serializer = AccountPMSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
return Response(AccountPMSerializer(obj).data, status=status.HTTP_201_CREATED)
qs = AccountPaymentMethod.objects.select_related('account').order_by('account_id', '-is_default', 'display_name')
account_id = request.query_params.get('account_id')
if account_id:
qs = qs.filter(account_id=account_id)
data = AccountPMSerializer(qs, many=True).data
return Response({'results': data, 'count': len(data)})
@action(detail=True, methods=['get', 'patch', 'put', 'delete'], url_path='account_payment_method')
@extend_schema(tags=['Admin Billing'])
def account_payment_method(self, request, pk=None):
"""Retrieve/update/delete an account payment method (admin)"""
error = self._require_admin(request)
if error:
return error
obj = get_object_or_404(AccountPaymentMethod, id=pk)
class AccountPMSerializer(serializers.ModelSerializer):
class Meta:
model = AccountPaymentMethod
fields = [
'id',
'account',
'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']
if request.method.lower() == 'get':
return Response(AccountPMSerializer(obj).data)
if request.method.lower() in ['patch', 'put']:
partial = request.method.lower() == 'patch'
serializer = AccountPMSerializer(obj, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
if serializer.validated_data.get('is_default'):
AccountPaymentMethod.objects.filter(account=obj.account).exclude(id=obj.id).update(is_default=False)
return Response(AccountPMSerializer(obj).data)
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['post'], url_path='account_payment_method/set_default')
@extend_schema(tags=['Admin Billing'])
def set_default_account_payment_method(self, request, pk=None):
"""Set default account payment method (admin)"""
error = self._require_admin(request)
if error:
return error
obj = get_object_or_404(AccountPaymentMethod, id=pk)
AccountPaymentMethod.objects.filter(account=obj.account).update(is_default=False)
obj.is_default = True
obj.save(update_fields=['is_default'])
return Response({'message': 'Default payment method updated', 'id': obj.id})
@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()
}
})