495 lines
19 KiB
Python
495 lines
19 KiB
Python
"""
|
|
Billing Module Admin
|
|
"""
|
|
from django.contrib import admin
|
|
from django.utils.html import format_html
|
|
from django.contrib import messages
|
|
from igny8_core.admin.base import AccountAdminMixin
|
|
from igny8_core.business.billing.models import (
|
|
CreditCostConfig,
|
|
Invoice,
|
|
Payment,
|
|
CreditPackage,
|
|
PaymentMethodConfig,
|
|
)
|
|
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
|
|
from import_export.admin import ExportMixin
|
|
from import_export import resources
|
|
from rangefilter.filters import DateRangeFilter
|
|
|
|
|
|
class CreditTransactionResource(resources.ModelResource):
|
|
"""Resource class for exporting Credit Transactions"""
|
|
class Meta:
|
|
model = CreditTransaction
|
|
fields = ('id', 'account__name', 'transaction_type', 'amount', 'balance_after',
|
|
'description', 'reference_id', 'created_at')
|
|
export_order = fields
|
|
|
|
|
|
@admin.register(CreditTransaction)
|
|
class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, admin.ModelAdmin):
|
|
resource_class = CreditTransactionResource
|
|
list_display = ['id', 'account', 'transaction_type', 'amount', 'balance_after', 'description', 'created_at']
|
|
list_filter = ['transaction_type', ('created_at', DateRangeFilter), 'account']
|
|
search_fields = ['description', 'account__name']
|
|
readonly_fields = ['created_at']
|
|
date_hierarchy = 'created_at'
|
|
|
|
def get_account_display(self, obj):
|
|
"""Safely get account name"""
|
|
try:
|
|
account = getattr(obj, 'account', None)
|
|
return account.name if account else '-'
|
|
except:
|
|
return '-'
|
|
get_account_display.short_description = 'Account'
|
|
|
|
|
|
@admin.register(CreditUsageLog)
|
|
class CreditUsageLogAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|
list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_used', 'created_at']
|
|
list_filter = ['operation_type', 'created_at', 'account', 'model_used']
|
|
search_fields = ['account__name', 'model_used']
|
|
readonly_fields = ['created_at']
|
|
date_hierarchy = 'created_at'
|
|
|
|
def get_account_display(self, obj):
|
|
"""Safely get account name"""
|
|
try:
|
|
account = getattr(obj, 'account', None)
|
|
return account.name if account else '-'
|
|
except:
|
|
return '-'
|
|
get_account_display.short_description = 'Account'
|
|
|
|
|
|
@admin.register(Invoice)
|
|
class InvoiceAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|
list_display = [
|
|
'invoice_number',
|
|
'account',
|
|
'status',
|
|
'total',
|
|
'currency',
|
|
'invoice_date',
|
|
'due_date',
|
|
]
|
|
list_filter = ['status', 'currency', 'invoice_date', 'account']
|
|
search_fields = ['invoice_number', 'account__name']
|
|
readonly_fields = ['created_at', 'updated_at']
|
|
|
|
|
|
class PaymentResource(resources.ModelResource):
|
|
"""Resource class for exporting Payments"""
|
|
class Meta:
|
|
model = Payment
|
|
fields = ('id', 'invoice__invoice_number', 'account__name', 'payment_method',
|
|
'status', 'amount', 'currency', 'manual_reference', 'approved_by__email',
|
|
'processed_at', 'created_at')
|
|
export_order = fields
|
|
|
|
|
|
@admin.register(Payment)
|
|
class PaymentAdmin(ExportMixin, AccountAdminMixin, admin.ModelAdmin):
|
|
"""
|
|
Main Payment Admin with approval workflow.
|
|
When you change status to 'succeeded', it automatically:
|
|
- Updates invoice to 'paid'
|
|
- Activates subscription
|
|
- Activates account
|
|
- Adds credits
|
|
"""
|
|
resource_class = PaymentResource
|
|
list_display = [
|
|
'id',
|
|
'invoice',
|
|
'account',
|
|
'payment_method',
|
|
'status',
|
|
'amount',
|
|
'currency',
|
|
'manual_reference',
|
|
'approved_by',
|
|
'processed_at',
|
|
]
|
|
list_filter = ['status', 'payment_method', 'currency', ('created_at', DateRangeFilter), ('processed_at', DateRangeFilter)]
|
|
search_fields = [
|
|
'invoice__invoice_number',
|
|
'account__name',
|
|
'stripe_payment_intent_id',
|
|
'paypal_order_id',
|
|
'manual_reference',
|
|
'admin_notes',
|
|
'manual_notes'
|
|
]
|
|
readonly_fields = ['created_at', 'updated_at', 'approved_at', 'processed_at', 'failed_at', 'refunded_at']
|
|
actions = ['approve_payments', 'reject_payments']
|
|
|
|
fieldsets = (
|
|
('Payment Info', {
|
|
'fields': ('invoice', 'account', 'amount', 'currency', 'payment_method', 'status')
|
|
}),
|
|
('Manual Payment Details', {
|
|
'fields': ('manual_reference', 'manual_notes', 'admin_notes'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
('Stripe/PayPal', {
|
|
'fields': ('stripe_payment_intent_id', 'stripe_charge_id', 'paypal_order_id', 'paypal_capture_id'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
('Approval Info', {
|
|
'fields': ('approved_by', 'approved_at', 'processed_at', 'failed_at', 'refunded_at', 'failure_reason'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
('Timestamps', {
|
|
'fields': ('created_at', 'updated_at'),
|
|
'classes': ('collapse',),
|
|
}),
|
|
)
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
"""
|
|
Override save_model to trigger approval workflow when status changes to succeeded.
|
|
This ensures manual status changes in admin also activate accounts and add credits.
|
|
"""
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from igny8_core.business.billing.services.credit_service import CreditService
|
|
from igny8_core.auth.models import Subscription
|
|
|
|
# Check if status changed to 'succeeded'
|
|
status_changed_to_succeeded = False
|
|
if change and 'status' in form.changed_data:
|
|
if obj.status == 'succeeded' and form.initial.get('status') != 'succeeded':
|
|
status_changed_to_succeeded = True
|
|
elif not change and obj.status == 'succeeded':
|
|
status_changed_to_succeeded = True
|
|
|
|
# Save the payment first
|
|
if obj.status == 'succeeded' and not obj.approved_by:
|
|
obj.approved_by = request.user
|
|
if not obj.approved_at:
|
|
obj.approved_at = timezone.now()
|
|
if not obj.processed_at:
|
|
obj.processed_at = timezone.now()
|
|
|
|
super().save_model(request, obj, form, change)
|
|
|
|
# If status changed to succeeded, trigger the full approval workflow
|
|
if status_changed_to_succeeded:
|
|
try:
|
|
with transaction.atomic():
|
|
invoice = obj.invoice
|
|
account = obj.account
|
|
|
|
# Get subscription from invoice or account
|
|
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 Subscription.DoesNotExist:
|
|
pass
|
|
|
|
# Update Invoice
|
|
if invoice and invoice.status != 'paid':
|
|
invoice.status = 'paid'
|
|
invoice.paid_at = timezone.now()
|
|
invoice.save()
|
|
|
|
# Update Subscription
|
|
if subscription and subscription.status != 'active':
|
|
subscription.status = 'active'
|
|
subscription.external_payment_id = obj.manual_reference
|
|
subscription.save()
|
|
|
|
# Update Account
|
|
if account.status != 'active':
|
|
account.status = 'active'
|
|
account.save()
|
|
|
|
# Add Credits (check if not already added)
|
|
from igny8_core.business.billing.models import CreditTransaction
|
|
existing_credit = CreditTransaction.objects.filter(
|
|
account=account,
|
|
metadata__payment_id=obj.id
|
|
).exists()
|
|
|
|
if not existing_credit:
|
|
credits_to_add = 0
|
|
plan_name = ''
|
|
|
|
if subscription and subscription.plan:
|
|
credits_to_add = subscription.plan.included_credits
|
|
plan_name = subscription.plan.name
|
|
elif account and account.plan:
|
|
credits_to_add = account.plan.included_credits
|
|
plan_name = account.plan.name
|
|
|
|
if credits_to_add > 0:
|
|
CreditService.add_credits(
|
|
account=account,
|
|
amount=credits_to_add,
|
|
transaction_type='subscription',
|
|
description=f'{plan_name} - Invoice {invoice.invoice_number}',
|
|
metadata={
|
|
'subscription_id': subscription.id if subscription else None,
|
|
'invoice_id': invoice.id,
|
|
'payment_id': obj.id,
|
|
'approved_by': request.user.email
|
|
}
|
|
)
|
|
self.message_user(
|
|
request,
|
|
f'✓ Payment approved: Account activated, {credits_to_add} credits added',
|
|
level='SUCCESS'
|
|
)
|
|
|
|
except Exception as e:
|
|
self.message_user(
|
|
request,
|
|
f'✗ Payment saved but workflow failed: {str(e)}',
|
|
level='ERROR'
|
|
)
|
|
|
|
def approve_payments(self, request, queryset):
|
|
"""Approve selected manual payments"""
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from igny8_core.business.billing.services.credit_service import CreditService
|
|
from igny8_core.auth.models import Subscription
|
|
|
|
successful = []
|
|
errors = []
|
|
|
|
for payment in queryset.filter(status='pending_approval'):
|
|
try:
|
|
with transaction.atomic():
|
|
invoice = payment.invoice
|
|
account = payment.account
|
|
|
|
# Get subscription from invoice or account
|
|
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 Subscription.DoesNotExist:
|
|
pass
|
|
|
|
# Update Payment
|
|
payment.status = 'succeeded'
|
|
payment.approved_by = request.user
|
|
payment.approved_at = timezone.now()
|
|
payment.processed_at = timezone.now()
|
|
payment.admin_notes = f'Bulk approved by {request.user.email}'
|
|
payment.save()
|
|
|
|
# Update Invoice
|
|
invoice.status = 'paid'
|
|
invoice.paid_at = timezone.now()
|
|
invoice.save()
|
|
|
|
# Update Subscription
|
|
if subscription:
|
|
subscription.status = 'active'
|
|
subscription.external_payment_id = payment.manual_reference
|
|
subscription.save()
|
|
|
|
# Update Account
|
|
account.status = 'active'
|
|
account.save()
|
|
|
|
# Add Credits
|
|
credits_added = 0
|
|
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
|
credits_added = subscription.plan.included_credits
|
|
CreditService.add_credits(
|
|
account=account,
|
|
amount=credits_added,
|
|
transaction_type='subscription',
|
|
description=f'{subscription.plan.name} - Invoice {invoice.invoice_number}',
|
|
metadata={
|
|
'subscription_id': subscription.id,
|
|
'invoice_id': invoice.id,
|
|
'payment_id': payment.id,
|
|
'approved_by': request.user.email
|
|
}
|
|
)
|
|
elif account and account.plan and account.plan.included_credits > 0:
|
|
credits_added = account.plan.included_credits
|
|
CreditService.add_credits(
|
|
account=account,
|
|
amount=credits_added,
|
|
transaction_type='subscription',
|
|
description=f'{account.plan.name} - Invoice {invoice.invoice_number}',
|
|
metadata={
|
|
'invoice_id': invoice.id,
|
|
'payment_id': payment.id,
|
|
'approved_by': request.user.email
|
|
}
|
|
)
|
|
|
|
successful.append(f'Payment #{payment.id} - {account.name} - Invoice {invoice.invoice_number} - {credits_added} credits')
|
|
|
|
except Exception as e:
|
|
errors.append(f'Payment #{payment.id}: {str(e)}')
|
|
|
|
# Detailed success message
|
|
if successful:
|
|
self.message_user(request, f'✓ Successfully approved {len(successful)} payment(s):', level='SUCCESS')
|
|
for msg in successful[:10]: # Show first 10
|
|
self.message_user(request, f' • {msg}', level='SUCCESS')
|
|
if len(successful) > 10:
|
|
self.message_user(request, f' ... and {len(successful) - 10} more', level='SUCCESS')
|
|
|
|
# Detailed error messages
|
|
if errors:
|
|
self.message_user(request, f'✗ Failed to approve {len(errors)} payment(s):', level='ERROR')
|
|
for error in errors:
|
|
self.message_user(request, f' • {error}', level='ERROR')
|
|
|
|
approve_payments.short_description = 'Approve selected manual payments'
|
|
|
|
def reject_payments(self, request, queryset):
|
|
"""Reject selected manual payments"""
|
|
from django.utils import timezone
|
|
|
|
count = queryset.filter(status='pending_approval').update(
|
|
status='failed',
|
|
approved_by=request.user,
|
|
approved_at=timezone.now(),
|
|
failed_at=timezone.now(),
|
|
admin_notes=f'Bulk rejected by {request.user.email}',
|
|
failure_reason='Rejected by admin'
|
|
)
|
|
|
|
self.message_user(request, f'Rejected {count} payment(s)')
|
|
|
|
reject_payments.short_description = 'Reject selected manual payments'
|
|
|
|
|
|
@admin.register(CreditPackage)
|
|
class CreditPackageAdmin(admin.ModelAdmin):
|
|
list_display = ['name', 'slug', 'credits', 'price', 'discount_percentage', 'is_active', 'is_featured', 'sort_order']
|
|
list_filter = ['is_active', 'is_featured']
|
|
search_fields = ['name', 'slug']
|
|
readonly_fields = ['created_at', 'updated_at']
|
|
|
|
|
|
@admin.register(PaymentMethodConfig)
|
|
class PaymentMethodConfigAdmin(admin.ModelAdmin):
|
|
list_display = ['country_code', 'payment_method', 'display_name', 'is_enabled', 'sort_order', 'updated_at']
|
|
list_filter = ['payment_method', 'is_enabled', 'country_code']
|
|
search_fields = ['country_code', 'display_name', 'payment_method']
|
|
list_editable = ['is_enabled', 'sort_order']
|
|
readonly_fields = ['created_at', 'updated_at']
|
|
|
|
|
|
@admin.register(AccountPaymentMethod)
|
|
class AccountPaymentMethodAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|
list_display = [
|
|
'display_name',
|
|
'type',
|
|
'account',
|
|
'is_default',
|
|
'is_enabled',
|
|
'is_verified',
|
|
'country_code',
|
|
'updated_at',
|
|
]
|
|
list_filter = ['type', 'is_default', 'is_enabled', 'is_verified', 'country_code']
|
|
search_fields = ['display_name', 'account__name', 'account__id']
|
|
readonly_fields = ['created_at', 'updated_at']
|
|
fieldsets = (
|
|
('Payment Method', {
|
|
'fields': ('account', 'type', 'display_name', 'is_default', 'is_enabled', 'is_verified', 'country_code')
|
|
}),
|
|
('Instructions / Metadata', {
|
|
'fields': ('instructions', 'metadata')
|
|
}),
|
|
('Timestamps', {
|
|
'fields': ('created_at', 'updated_at'),
|
|
'classes': ('collapse',)
|
|
}),
|
|
)
|
|
|
|
|
|
@admin.register(CreditCostConfig)
|
|
class CreditCostConfigAdmin(admin.ModelAdmin):
|
|
list_display = [
|
|
'operation_type',
|
|
'display_name',
|
|
'credits_cost_display',
|
|
'unit',
|
|
'is_active',
|
|
'cost_change_indicator',
|
|
'updated_at',
|
|
'updated_by'
|
|
]
|
|
|
|
list_filter = ['is_active', 'unit', 'updated_at']
|
|
search_fields = ['operation_type', 'display_name', 'description']
|
|
|
|
fieldsets = (
|
|
('Operation', {
|
|
'fields': ('operation_type', 'display_name', 'description')
|
|
}),
|
|
('Cost Configuration', {
|
|
'fields': ('credits_cost', 'unit', 'is_active')
|
|
}),
|
|
('Audit Trail', {
|
|
'fields': ('previous_cost', 'updated_by', 'created_at', 'updated_at'),
|
|
'classes': ('collapse',)
|
|
}),
|
|
)
|
|
|
|
readonly_fields = ['created_at', 'updated_at', 'previous_cost']
|
|
|
|
def credits_cost_display(self, obj):
|
|
"""Show cost with color coding"""
|
|
if obj.credits_cost >= 20:
|
|
color = 'red'
|
|
elif obj.credits_cost >= 10:
|
|
color = 'orange'
|
|
else:
|
|
color = 'green'
|
|
return format_html(
|
|
'<span style="color: {}; font-weight: bold;">{} credits</span>',
|
|
color,
|
|
obj.credits_cost
|
|
)
|
|
credits_cost_display.short_description = 'Cost'
|
|
|
|
def cost_change_indicator(self, obj):
|
|
"""Show if cost changed recently"""
|
|
if obj.previous_cost is not None:
|
|
if obj.credits_cost > obj.previous_cost:
|
|
icon = '📈' # Increased
|
|
color = 'red'
|
|
elif obj.credits_cost < obj.previous_cost:
|
|
icon = '📉' # Decreased
|
|
color = 'green'
|
|
else:
|
|
icon = '➡️' # Same
|
|
color = 'gray'
|
|
|
|
return format_html(
|
|
'{} <span style="color: {};">({} → {})</span>',
|
|
icon,
|
|
color,
|
|
obj.previous_cost,
|
|
obj.credits_cost
|
|
)
|
|
return '—'
|
|
cost_change_indicator.short_description = 'Recent Change'
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
"""Track who made the change"""
|
|
obj.updated_by = request.user
|
|
super().save_model(request, obj, form, change)
|
|
|