diff --git a/backend/igny8_core/api/account_views.py b/backend/igny8_core/api/account_views.py index ef0d67fc..512d0943 100644 --- a/backend/igny8_core/api/account_views.py +++ b/backend/igny8_core/api/account_views.py @@ -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, diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index 057ed4ae..a5a09ab5 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -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, diff --git a/backend/igny8_core/business/billing/services/invoice_service.py b/backend/igny8_core/business/billing/services/invoice_service.py index 6cfb2906..3b463411 100644 --- a/backend/igny8_core/business/billing/services/invoice_service.py +++ b/backend/igny8_core/business/billing/services/invoice_service.py @@ -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)) ) diff --git a/backend/igny8_core/business/billing/services/payment_service.py b/backend/igny8_core/business/billing/services/payment_service.py index 98e6b811..816aa3f9 100644 --- a/backend/igny8_core/business/billing/services/payment_service.py +++ b/backend/igny8_core/business/billing/services/payment_service.py @@ -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 diff --git a/backend/igny8_core/business/billing/views.py b/backend/igny8_core/business/billing/views.py index 7485d8fc..03720ea4 100644 --- a/backend/igny8_core/business/billing/views.py +++ b/backend/igny8_core/business/billing/views.py @@ -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 = [ diff --git a/backend/igny8_core/modules/billing/migrations/0005_credittransaction_reference_id_invoice_billing_email_and_more.py b/backend/igny8_core/modules/billing/migrations/0005_credittransaction_reference_id_invoice_billing_email_and_more.py new file mode 100644 index 00000000..73093ea0 --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0005_credittransaction_reference_id_invoice_billing_email_and_more.py @@ -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), + ), + ] diff --git a/frontend/src/pages/Admin/AdminBilling.tsx b/frontend/src/pages/Admin/AdminBilling.tsx index 92945101..53dc9a9e 100644 --- a/frontend/src/pages/Admin/AdminBilling.tsx +++ b/frontend/src/pages/Admin/AdminBilling.tsx @@ -65,7 +65,7 @@ const AdminBilling: React.FC = () => { try { setLoading(true); const [statsData, usersData, configsData] = await Promise.all([ - fetchAPI('/v1/admin/billing/stats/'), + fetchAPI('/v1/billing/admin/stats/'), fetchAPI('/v1/admin/users/?limit=100'), fetchAPI('/v1/admin/credit-costs/'), ]); diff --git a/frontend/src/pages/account/AccountBillingPage.tsx b/frontend/src/pages/account/AccountBillingPage.tsx index af3db386..89605644 100644 --- a/frontend/src/pages/account/AccountBillingPage.tsx +++ b/frontend/src/pages/account/AccountBillingPage.tsx @@ -205,7 +205,7 @@ export default function AccountBillingPage() {