fixes fixes fixes tenaancy
This commit is contained in:
@@ -1,12 +1,113 @@
|
||||
"""
|
||||
Admin interface for auth models
|
||||
"""
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from igny8_core.admin.base import AccountAdminMixin
|
||||
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken
|
||||
|
||||
|
||||
class AccountAdminForm(forms.ModelForm):
|
||||
"""Custom form for Account admin with dynamic payment method choices from PaymentMethodConfig"""
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
fields = '__all__'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
from igny8_core.business.billing.models import PaymentMethodConfig, AccountPaymentMethod
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
# Get country from billing_country, fallback to wildcard '*' for global
|
||||
country = self.instance.billing_country or '*'
|
||||
|
||||
# Get enabled payment methods for this country OR global (*)
|
||||
available_methods = PaymentMethodConfig.objects.filter(
|
||||
country_code__in=[country, '*'],
|
||||
is_enabled=True
|
||||
).order_by('country_code', 'sort_order').values_list('payment_method', 'display_name')
|
||||
|
||||
if available_methods:
|
||||
# Build choices from PaymentMethodConfig
|
||||
choices = []
|
||||
seen = set()
|
||||
for method_type, display_name in available_methods:
|
||||
if method_type not in seen:
|
||||
choices.append((method_type, display_name or method_type.replace('_', ' ').title()))
|
||||
seen.add(method_type)
|
||||
else:
|
||||
# Fallback to model choices if no configs
|
||||
choices = Account.PAYMENT_METHOD_CHOICES
|
||||
|
||||
self.fields['payment_method'].widget = forms.Select(choices=choices)
|
||||
|
||||
# Get current default from AccountPaymentMethod
|
||||
default_method = AccountPaymentMethod.objects.filter(
|
||||
account=self.instance,
|
||||
is_default=True,
|
||||
is_enabled=True
|
||||
).first()
|
||||
|
||||
if default_method:
|
||||
self.fields['payment_method'].initial = default_method.type
|
||||
self.fields['payment_method'].help_text = f'✓ Current: {default_method.display_name} ({default_method.get_type_display()})'
|
||||
else:
|
||||
self.fields['payment_method'].help_text = 'Select from available payment methods based on country'
|
||||
|
||||
def save(self, commit=True):
|
||||
"""When payment_method changes, update/create AccountPaymentMethod"""
|
||||
from igny8_core.business.billing.models import AccountPaymentMethod, PaymentMethodConfig
|
||||
|
||||
instance = super().save(commit=False)
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
# Get selected payment method
|
||||
selected_type = self.cleaned_data.get('payment_method')
|
||||
|
||||
if selected_type:
|
||||
# Get config for display name and instructions
|
||||
country = instance.billing_country or '*'
|
||||
config = PaymentMethodConfig.objects.filter(
|
||||
country_code__in=[country, '*'],
|
||||
payment_method=selected_type,
|
||||
is_enabled=True
|
||||
).first()
|
||||
|
||||
# Create or update AccountPaymentMethod
|
||||
account_method, created = AccountPaymentMethod.objects.get_or_create(
|
||||
account=instance,
|
||||
type=selected_type,
|
||||
defaults={
|
||||
'display_name': config.display_name if config else selected_type.replace('_', ' ').title(),
|
||||
'is_default': True,
|
||||
'is_enabled': True,
|
||||
'instructions': config.instructions if config else '',
|
||||
'country_code': instance.billing_country or '',
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update existing and set as default
|
||||
account_method.is_default = True
|
||||
account_method.is_enabled = True
|
||||
if config:
|
||||
account_method.display_name = config.display_name
|
||||
account_method.instructions = config.instructions
|
||||
account_method.save()
|
||||
|
||||
# Unset other methods as default
|
||||
AccountPaymentMethod.objects.filter(
|
||||
account=instance
|
||||
).exclude(id=account_method.id).update(is_default=False)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
@admin.register(Plan)
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
"""Plan admin - Global, no account filtering needed"""
|
||||
@@ -33,6 +134,7 @@ class PlanAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Account)
|
||||
class AccountAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
form = AccountAdminForm
|
||||
list_display = ['name', 'slug', 'owner', 'plan', 'status', 'credits', 'created_at']
|
||||
list_filter = ['status', 'plan']
|
||||
search_fields = ['name', 'slug']
|
||||
|
||||
@@ -108,6 +108,11 @@ class InvoiceAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
|
||||
@admin.register(Payment)
|
||||
class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
\"\"\"
|
||||
Payment admin - DO NOT USE.
|
||||
Use the Payment admin in modules/billing/admin.py which has approval workflow actions.
|
||||
This is kept for backward compatibility only.
|
||||
\"\"\"
|
||||
list_display = [
|
||||
'id',
|
||||
'invoice',
|
||||
@@ -121,6 +126,9 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
list_filter = ['status', 'payment_method', 'currency', 'created_at']
|
||||
search_fields = ['invoice__invoice_number', 'account__name', 'stripe_payment_intent_id', 'paypal_order_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
def has_add_permission(self, request):\n return False # Prevent creating payments here
|
||||
\n def has_delete_permission(self, request, obj=None):\n return False # Prevent deleting payments here
|
||||
|
||||
|
||||
@admin.register(CreditPackage)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated migration for Payment status simplification
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_payment_statuses(apps, schema_editor):
|
||||
"""
|
||||
Migrate old payment statuses to new simplified statuses:
|
||||
- pending, processing, completed, cancelled → map to new statuses
|
||||
"""
|
||||
Payment = apps.get_model('billing', 'Payment')
|
||||
|
||||
# Map old statuses to new statuses
|
||||
status_mapping = {
|
||||
'pending': 'pending_approval', # Treat as pending approval
|
||||
'processing': 'pending_approval', # Treat as pending approval
|
||||
'completed': 'succeeded', # completed = succeeded
|
||||
'cancelled': 'failed', # cancelled = failed
|
||||
# Keep existing: pending_approval, succeeded, failed, refunded
|
||||
}
|
||||
|
||||
for old_status, new_status in status_mapping.items():
|
||||
Payment.objects.filter(status=old_status).update(status=new_status)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0006_auto_20251209_payment_workflow'), # Adjust to your latest migration
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Update status choices (Django will handle this in model)
|
||||
migrations.RunPython(migrate_payment_statuses, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -240,12 +240,16 @@ class Invoice(AccountBaseModel):
|
||||
@property
|
||||
def billing_period_start(self):
|
||||
"""Get from subscription - single source of truth"""
|
||||
return self.subscription.current_period_start if self.subscription else None
|
||||
if self.account and hasattr(self.account, 'subscription'):
|
||||
return self.account.subscription.current_period_start
|
||||
return None
|
||||
|
||||
@property
|
||||
def billing_period_end(self):
|
||||
"""Get from subscription - single source of truth"""
|
||||
return self.subscription.current_period_end if self.subscription else None
|
||||
if self.account and hasattr(self.account, 'subscription'):
|
||||
return self.account.subscription.current_period_end
|
||||
return None
|
||||
|
||||
@property
|
||||
def billing_email(self):
|
||||
@@ -285,14 +289,10 @@ class Payment(AccountBaseModel):
|
||||
Supports: Stripe, PayPal, Manual (Bank Transfer, Local Wallet)
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('pending_approval', 'Pending Approval'),
|
||||
('processing', 'Processing'),
|
||||
('succeeded', 'Succeeded'),
|
||||
('completed', 'Completed'), # Legacy alias for succeeded
|
||||
('failed', 'Failed'),
|
||||
('refunded', 'Refunded'),
|
||||
('cancelled', 'Cancelled'),
|
||||
('pending_approval', 'Pending Approval'), # Manual payment submitted by user
|
||||
('succeeded', 'Succeeded'), # Payment approved and processed
|
||||
('failed', 'Failed'), # Payment rejected or failed
|
||||
('refunded', 'Refunded'), # Payment refunded (rare)
|
||||
]
|
||||
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
@@ -366,6 +366,85 @@ class Payment(AccountBaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"Payment {self.id} - {self.get_payment_method_display()} - {self.amount} {self.currency}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save to automatically update related objects when payment is approved.
|
||||
When status changes to 'succeeded', automatically:
|
||||
1. Mark invoice as paid
|
||||
2. Activate subscription
|
||||
3. Activate account
|
||||
4. Add credits
|
||||
"""
|
||||
# Check if status is changing to succeeded
|
||||
is_new = self.pk is None
|
||||
old_status = None
|
||||
|
||||
if not is_new:
|
||||
try:
|
||||
old_payment = Payment.objects.get(pk=self.pk)
|
||||
old_status = old_payment.status
|
||||
except Payment.DoesNotExist:
|
||||
pass
|
||||
|
||||
# If status is changing to succeeded, trigger approval workflow
|
||||
if self.status == 'succeeded' and old_status != 'succeeded':
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
|
||||
# Set approval timestamp if not set
|
||||
if not self.processed_at:
|
||||
self.processed_at = timezone.now()
|
||||
if not self.approved_at:
|
||||
self.approved_at = timezone.now()
|
||||
|
||||
# Save payment first
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Then update related objects in transaction
|
||||
with transaction.atomic():
|
||||
# 1. Update Invoice
|
||||
if self.invoice:
|
||||
self.invoice.status = 'paid'
|
||||
self.invoice.paid_at = timezone.now()
|
||||
self.invoice.save(update_fields=['status', 'paid_at'])
|
||||
|
||||
# 2. Update Account (MUST be before subscription check)
|
||||
if self.account:
|
||||
self.account.status = 'active'
|
||||
self.account.save(update_fields=['status'])
|
||||
|
||||
# 3. Update Subscription via account.subscription (one-to-one relationship)
|
||||
try:
|
||||
if hasattr(self.account, 'subscription'):
|
||||
subscription = self.account.subscription
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = self.manual_reference or f'payment-{self.id}'
|
||||
subscription.save(update_fields=['status', 'external_payment_id'])
|
||||
|
||||
# 4. Add Credits from subscription plan
|
||||
if subscription.plan and subscription.plan.included_credits > 0:
|
||||
CreditService.add_credits(
|
||||
account=self.account,
|
||||
amount=subscription.plan.included_credits,
|
||||
transaction_type='subscription',
|
||||
description=f'{subscription.plan.name} - Invoice {self.invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id,
|
||||
'invoice_id': self.invoice.id,
|
||||
'payment_id': self.id,
|
||||
'auto_approved': True
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't fail payment save
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error updating subscription/credits for payment {self.id}: {e}', exc_info=True)
|
||||
else:
|
||||
# Normal save
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CreditPackage(models.Model):
|
||||
|
||||
@@ -25,6 +25,7 @@ router.register(r'invoices', InvoiceViewSet, basename='invoices')
|
||||
router.register(r'payments', PaymentViewSet, basename='payments')
|
||||
router.register(r'credit-packages', CreditPackageViewSet, basename='credit-packages')
|
||||
router.register(r'payment-methods', AccountPaymentMethodViewSet, basename='payment-methods')
|
||||
router.register(r'payment-configs', BillingViewSet, basename='payment-configs')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
@@ -68,6 +68,14 @@ class InvoiceAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
|
||||
@admin.register(Payment)
|
||||
class PaymentAdmin(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
|
||||
"""
|
||||
list_display = [
|
||||
'id',
|
||||
'invoice',
|
||||
@@ -93,6 +101,37 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
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 set approved_by when status changes to succeeded.
|
||||
The Payment.save() method will handle all the cascade updates automatically.
|
||||
"""
|
||||
if obj.status == 'succeeded' and not obj.approved_by:
|
||||
obj.approved_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def approve_payments(self, request, queryset):
|
||||
"""Approve selected manual payments"""
|
||||
from django.db import transaction
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by GitHub Copilot on 2025-12-09 02:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0006_accountpaymentmethod'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('pending_approval', 'Pending Approval'),
|
||||
('succeeded', 'Succeeded'),
|
||||
('failed', 'Failed'),
|
||||
('refunded', 'Refunded')
|
||||
],
|
||||
db_index=True,
|
||||
default='pending_approval',
|
||||
max_length=20
|
||||
),
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user