billing admin account 1

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-05 08:01:55 +00:00
parent f91037b729
commit 1e718105f2
12 changed files with 378 additions and 85 deletions

View File

@@ -222,7 +222,7 @@ class UsageAnalyticsViewSet(viewsets.ViewSet):
'period_days': days,
'start_date': start_date.isoformat(),
'end_date': timezone.now().isoformat(),
'current_balance': account.credit_balance,
'current_balance': account.credits,
'usage_by_type': list(usage_by_type),
'purchases_by_type': list(purchases_by_type),
'daily_usage': daily_usage,

View File

@@ -1,6 +1,7 @@
"""
Billing Models for Credit System
"""
from decimal import Decimal
from django.db import models
from django.core.validators import MinValueValidator
from django.conf import settings
@@ -22,6 +23,11 @@ class CreditTransaction(AccountBaseModel):
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
description = models.CharField(max_length=255)
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
reference_id = models.CharField(
max_length=255,
blank=True,
help_text="Optional reference (e.g., payment id, invoice id)"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -183,9 +189,10 @@ class Invoice(AccountBaseModel):
)
# Amounts
subtotal = models.DecimalField(max_digits=10, decimal_places=2)
subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0)
tax = models.DecimalField(max_digits=10, decimal_places=2, default=0)
total = models.DecimalField(max_digits=10, decimal_places=2)
total = models.DecimalField(max_digits=10, decimal_places=2, default=0)
currency = models.CharField(max_length=3, default='USD')
# Status
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True)
@@ -201,6 +208,9 @@ class Invoice(AccountBaseModel):
# Payment integration
stripe_invoice_id = models.CharField(max_length=255, null=True, blank=True)
payment_method = models.CharField(max_length=50, null=True, blank=True)
billing_email = models.EmailField(null=True, blank=True)
billing_period_start = models.DateTimeField(null=True, blank=True)
billing_period_end = models.DateTimeField(null=True, blank=True)
# Metadata
notes = models.TextField(blank=True)
@@ -222,6 +232,45 @@ class Invoice(AccountBaseModel):
def __str__(self):
return f"Invoice {self.invoice_number} - {self.account.name if self.account else 'No Account'}"
# ------------------------------------------------------------------
# Helpers to keep service code working with legacy field names
# ------------------------------------------------------------------
@property
def subtotal_amount(self):
return self.subtotal
@property
def tax_amount(self):
return self.tax
@property
def total_amount(self):
return self.total
def add_line_item(self, description: str, quantity: int, unit_price: Decimal, amount: Decimal = None):
"""Append a line item and keep JSON shape consistent."""
items = list(self.line_items or [])
qty = quantity or 1
amt = Decimal(amount) if amount is not None else Decimal(unit_price) * qty
items.append({
'description': description,
'quantity': qty,
'unit_price': str(unit_price),
'amount': str(amt),
})
self.line_items = items
def calculate_totals(self):
"""Recompute subtotal, tax, and total from line_items."""
subtotal = Decimal('0')
for item in self.line_items or []:
try:
subtotal += Decimal(str(item.get('amount') or 0))
except Exception:
pass
self.subtotal = subtotal
self.total = subtotal + (self.tax or Decimal('0'))
class Payment(AccountBaseModel):
"""
@@ -230,8 +279,10 @@ class Payment(AccountBaseModel):
"""
STATUS_CHOICES = [
('pending', 'Pending'),
('pending_approval', 'Pending Approval'),
('processing', 'Processing'),
('succeeded', 'Succeeded'),
('completed', 'Completed'), # Legacy alias for succeeded
('failed', 'Failed'),
('refunded', 'Refunded'),
('cancelled', 'Cancelled'),
@@ -272,6 +323,8 @@ class Payment(AccountBaseModel):
help_text="Bank transfer reference, wallet transaction ID, etc."
)
manual_notes = models.TextField(blank=True, help_text="Admin notes for manual payments")
transaction_reference = models.CharField(max_length=255, blank=True)
admin_notes = models.TextField(blank=True, help_text="Internal notes on approval/rejection")
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,

View File

@@ -52,6 +52,8 @@ class InvoiceService:
billing_email=account.billing_email or account.users.filter(role='owner').first().email,
status='pending',
currency='USD',
invoice_date=timezone.now().date(),
due_date=billing_period_end.date(),
billing_period_start=billing_period_start,
billing_period_end=billing_period_end
)
@@ -83,7 +85,13 @@ class InvoiceService:
invoice_number=InvoiceService.generate_invoice_number(account),
billing_email=account.billing_email or account.users.filter(role='owner').first().email,
status='pending',
currency='USD'
currency='USD',
invoice_date=timezone.now().date(),
due_date=timezone.now().date(),
metadata={
'credit_package_id': credit_package.id,
'credit_amount': credit_package.credits,
},
)
# Add line item for credit package
@@ -125,6 +133,7 @@ class InvoiceService:
status='draft',
currency='USD',
notes=notes,
invoice_date=timezone.now().date(),
due_date=due_date or (timezone.now() + timedelta(days=30))
)

View File

@@ -77,6 +77,12 @@ class PaymentService:
if payment_method not in ['bank_transfer', 'local_wallet', 'manual']:
raise ValueError("Invalid manual payment method")
meta = metadata or {}
# propagate credit package metadata from invoice if present
if invoice.metadata.get('credit_package_id'):
meta.setdefault('credit_package_id', invoice.metadata.get('credit_package_id'))
meta.setdefault('credit_amount', invoice.metadata.get('credit_amount'))
payment = Payment.objects.create(
account=invoice.account,
invoice=invoice,
@@ -86,7 +92,7 @@ class PaymentService:
status='pending_approval',
transaction_reference=transaction_reference,
admin_notes=admin_notes,
metadata=metadata or {}
metadata=meta
)
return payment
@@ -102,7 +108,7 @@ class PaymentService:
"""
from .invoice_service import InvoiceService
payment.status = 'completed'
payment.status = 'succeeded'
payment.processed_at = timezone.now()
if transaction_id:
@@ -153,9 +159,10 @@ class PaymentService:
if payment.status != 'pending_approval':
raise ValueError("Payment is not pending approval")
payment.status = 'completed'
payment.status = 'succeeded'
payment.processed_at = timezone.now()
payment.approved_by_id = approved_by_user_id
payment.approved_at = timezone.now()
if admin_notes:
payment.admin_notes = f"{payment.admin_notes}\n\nApproval notes: {admin_notes}" if payment.admin_notes else admin_notes
@@ -212,6 +219,11 @@ class PaymentService:
except CreditPackage.DoesNotExist:
return
# Update account balance
account: Account = payment.account
account.credits = (account.credits or 0) + credit_package.credits
account.save(update_fields=['credits', 'updated_at'])
# Create credit transaction
CreditTransaction.objects.create(
account=payment.account,
@@ -244,14 +256,20 @@ class PaymentService:
return {
'methods': [
{
'id': 'stripe-default',
'type': 'stripe',
'name': 'Credit/Debit Card',
'instructions': 'Pay securely with your credit or debit card'
'display_name': 'Credit/Debit Card',
'instructions': 'Pay securely with your credit or debit card',
'is_enabled': True,
},
{
'id': 'paypal-default',
'type': 'paypal',
'name': 'PayPal',
'instructions': 'Pay with your PayPal account'
'display_name': 'PayPal',
'instructions': 'Pay with your PayPal account',
'is_enabled': True,
}
],
'stripe': True,
@@ -272,9 +290,12 @@ class PaymentService:
for config in configs:
method_flags[config.payment_method] = True
method_data = {
'id': f"{config.country_code}-{config.payment_method}-{config.id}",
'type': config.payment_method,
'name': config.display_name or config.get_payment_method_display(),
'instructions': config.instructions
'display_name': config.display_name or config.get_payment_method_display(),
'instructions': config.instructions,
'is_enabled': True,
}
# Add bank details if bank_transfer
@@ -323,8 +344,8 @@ class PaymentService:
TODO: Implement actual refund logic for Stripe/PayPal
For now, just mark as refunded
"""
if payment.status != 'completed':
raise ValueError("Can only refund completed payments")
if payment.status not in ['completed', 'succeeded']:
raise ValueError("Can only refund succeeded/complete payments")
refund_amount = amount or payment.amount
@@ -338,8 +359,9 @@ class PaymentService:
amount=-refund_amount, # Negative amount for refund
currency=payment.currency,
payment_method=payment.payment_method,
status='completed',
status='refunded',
processed_at=timezone.now(),
refunded_at=timezone.now(),
metadata={
'refund_for_payment_id': payment.id,
'refund_reason': reason,
@@ -348,10 +370,16 @@ class PaymentService:
)
# Update original payment metadata
payment.metadata['refunded'] = True
payment.metadata['refund_payment_id'] = refund.id
payment.metadata['refund_amount'] = str(refund_amount)
payment.save()
meta = payment.metadata or {}
meta['refunded'] = True
meta['refund_payment_id'] = refund.id
meta['refund_amount'] = str(refund_amount)
if reason:
meta['refund_reason'] = reason
payment.metadata = meta
payment.status = 'refunded'
payment.refunded_at = timezone.now()
payment.save(update_fields=['metadata', 'status', 'refunded_at', 'updated_at'])
return refund

View File

@@ -8,6 +8,7 @@ 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
from .services.invoice_service import InvoiceService
@@ -126,16 +127,21 @@ class PaymentViewSet(viewsets.ViewSet):
"""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(methods)
return Response({
'results': method_list,
'count': len(method_list),
**methods
})
@action(detail=False, methods=['post'])
@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')
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]):
@@ -261,22 +267,32 @@ class CreditTransactionViewSet(viewsets.ViewSet):
for txn in transactions
],
'count': transactions.count(),
'current_balance': account.credit_balance
'current_balance': account.credits
})
@action(detail=False, methods=['get'])
def balance(self, request):
"""Get current credit balance"""
account = request.user.account
# Get subscription details
active_subscription = account.subscriptions.filter(status='active').first()
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({
'balance': account.credit_balance,
'subscription_plan': active_subscription.plan.name if active_subscription else 'None',
'monthly_credits': active_subscription.plan.monthly_credits if active_subscription else 0,
'subscription_status': active_subscription.status if active_subscription else None
'credits': account.credits,
'plan_credits_per_month': included,
'credits_used_this_month': used_this_month,
'credits_remaining': max(account.credits, 0),
})
@@ -284,15 +300,95 @@ class AdminBillingViewSet(viewsets.ViewSet):
"""Admin billing management"""
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def pending_payments(self, request):
"""List payments pending approval"""
# Check admin permission
if not request.user.is_staff:
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()
@@ -317,11 +413,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
@action(detail=True, methods=['post'])
def approve_payment(self, request, pk=None):
"""Approve a manual payment"""
if not request.user.is_staff:
return Response(
{'error': 'Admin access required'},
status=status.HTTP_403_FORBIDDEN
)
error = self._require_admin(request)
if error:
return error
payment = get_object_or_404(Payment, id=pk)
admin_notes = request.data.get('notes')
@@ -347,11 +441,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
@action(detail=True, methods=['post'])
def reject_payment(self, request, pk=None):
"""Reject a manual payment"""
if not request.user.is_staff:
return Response(
{'error': 'Admin access required'},
status=status.HTTP_403_FORBIDDEN
)
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')
@@ -377,11 +469,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
@action(detail=False, methods=['get'])
def stats(self, request):
"""System billing stats"""
if not request.user.is_staff:
return Response(
{'error': 'Admin access required'},
status=status.HTTP_403_FORBIDDEN
)
error = self._require_admin(request)
if error:
return error
from django.db.models import Sum, Count
from ...auth.models import Account
@@ -407,12 +497,12 @@ class AdminBillingViewSet(viewsets.ViewSet):
# Revenue stats
total_revenue = Payment.objects.filter(
status='completed',
status__in=['completed', 'succeeded'],
amount__gt=0
).aggregate(total=Sum('amount'))['total'] or 0
revenue_this_month = Payment.objects.filter(
status='completed',
status__in=['completed', 'succeeded'],
processed_at__gte=this_month_start,
amount__gt=0
).aggregate(total=Sum('amount'))['total'] or 0
@@ -430,9 +520,7 @@ class AdminBillingViewSet(viewsets.ViewSet):
).aggregate(total=Sum('amount'))['total'] or 0)
# Payment/Invoice stats
pending_approvals = Payment.objects.filter(
status='pending_approval'
).count()
pending_approvals = Payment.objects.filter(status='pending_approval').count()
invoices_pending = Invoice.objects.filter(status='pending').count()
invoices_overdue = Invoice.objects.filter(
@@ -442,7 +530,7 @@ class AdminBillingViewSet(viewsets.ViewSet):
# Recent activity
recent_payments = Payment.objects.filter(
status='completed'
status__in=['completed', 'succeeded']
).order_by('-processed_at')[:5]
recent_activity = [

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.2.8 on 2025-12-05 07:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0004_add_invoice_payment_models'),
]
operations = [
migrations.AddField(
model_name='credittransaction',
name='reference_id',
field=models.CharField(blank=True, help_text='Optional reference (e.g., payment id, invoice id)', max_length=255),
),
migrations.AddField(
model_name='invoice',
name='billing_email',
field=models.EmailField(blank=True, max_length=254, null=True),
),
migrations.AddField(
model_name='invoice',
name='billing_period_end',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='invoice',
name='billing_period_start',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='invoice',
name='currency',
field=models.CharField(default='USD', max_length=3),
),
migrations.AddField(
model_name='payment',
name='admin_notes',
field=models.TextField(blank=True, help_text='Internal notes on approval/rejection'),
),
migrations.AddField(
model_name='payment',
name='transaction_reference',
field=models.CharField(blank=True, max_length=255),
),
migrations.AlterField(
model_name='invoice',
name='subtotal',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
migrations.AlterField(
model_name='invoice',
name='total',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
migrations.AlterField(
model_name='payment',
name='status',
field=models.CharField(choices=[('pending', 'Pending'), ('pending_approval', 'Pending Approval'), ('processing', 'Processing'), ('succeeded', 'Succeeded'), ('completed', 'Completed'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20),
),
]