many fixes
This commit is contained in:
@@ -3,7 +3,7 @@ Billing Business Logic Admin
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from .models import CreditCostConfig
|
||||
from .models import CreditCostConfig, AccountPaymentMethod
|
||||
|
||||
|
||||
@admin.register(CreditCostConfig)
|
||||
@@ -79,3 +79,32 @@ class CreditCostConfigAdmin(admin.ModelAdmin):
|
||||
"""Track who made the change"""
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(AccountPaymentMethod)
|
||||
class AccountPaymentMethodAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'display_name',
|
||||
'type',
|
||||
'account',
|
||||
'is_default',
|
||||
'is_enabled',
|
||||
'country_code',
|
||||
'is_verified',
|
||||
'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',)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -457,3 +457,43 @@ class PaymentMethodConfig(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.country_code} - {self.get_payment_method_display()}"
|
||||
|
||||
|
||||
class AccountPaymentMethod(AccountBaseModel):
|
||||
"""
|
||||
Account-scoped payment methods (Stripe/PayPal/manual bank/wallet).
|
||||
Only metadata/refs are stored here; no secrets.
|
||||
"""
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer'),
|
||||
('local_wallet', 'Local Wallet'),
|
||||
]
|
||||
|
||||
type = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True)
|
||||
display_name = models.CharField(max_length=100, help_text="User-visible label", default='')
|
||||
is_default = models.BooleanField(default=False, db_index=True)
|
||||
is_enabled = models.BooleanField(default=True, db_index=True)
|
||||
is_verified = models.BooleanField(default=False, db_index=True)
|
||||
country_code = models.CharField(max_length=2, blank=True, default='', help_text="ISO-2 country code (optional)")
|
||||
|
||||
# Manual/bank/local wallet details (non-sensitive metadata)
|
||||
instructions = models.TextField(blank=True, default='')
|
||||
metadata = models.JSONField(default=dict, blank=True, help_text="Provider references or display metadata")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'billing'
|
||||
db_table = 'igny8_account_payment_methods'
|
||||
ordering = ['-is_default', 'display_name', 'id']
|
||||
indexes = [
|
||||
models.Index(fields=['account', 'is_default']),
|
||||
models.Index(fields=['account', 'type']),
|
||||
]
|
||||
unique_together = [['account', 'display_name']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.account_id} - {self.display_name} ({self.type})"
|
||||
|
||||
@@ -8,7 +8,8 @@ from .views import (
|
||||
PaymentViewSet,
|
||||
CreditPackageViewSet,
|
||||
CreditTransactionViewSet,
|
||||
AdminBillingViewSet
|
||||
AdminBillingViewSet,
|
||||
AccountPaymentMethodViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -17,9 +18,10 @@ 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'admin', AdminBillingViewSet, basename='admin-billing')
|
||||
router.register(r'payment-methods', AccountPaymentMethodViewSet, basename='payment-method')
|
||||
|
||||
urlpatterns = [
|
||||
# Payment methods alias for easier frontend access
|
||||
path('payment-methods/', PaymentViewSet.as_view({'get': 'available_methods'}), name='payment-methods'),
|
||||
# Country/config-driven available methods (legacy alias)
|
||||
path('payment-methods/available/', PaymentViewSet.as_view({'get': 'available_methods'}), name='payment-methods-available'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Billing API Views
|
||||
Comprehensive billing endpoints for invoices, payments, credit packages
|
||||
"""
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework import viewsets, status, serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -10,7 +10,14 @@ 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 .models import (
|
||||
Invoice,
|
||||
Payment,
|
||||
CreditPackage,
|
||||
PaymentMethodConfig,
|
||||
CreditTransaction,
|
||||
AccountPaymentMethod,
|
||||
)
|
||||
from .services.invoice_service import InvoiceService
|
||||
from .services.payment_service import PaymentService
|
||||
|
||||
@@ -172,6 +179,69 @@ class PaymentViewSet(viewsets.ViewSet):
|
||||
}, 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):
|
||||
"""
|
||||
CRUD for account-scoped payment methods (Stripe/PayPal/manual bank/local_wallet).
|
||||
"""
|
||||
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]
|
||||
@@ -485,14 +555,15 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
|
||||
# Account stats
|
||||
total_accounts = Account.objects.count()
|
||||
active_accounts = Account.objects.filter(is_active=True).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(
|
||||
subscriptions__status='active'
|
||||
subscription__status='active'
|
||||
).distinct().count()
|
||||
|
||||
# Revenue stats
|
||||
@@ -513,8 +584,8 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
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(
|
||||
transaction_type__in=['generate_content', 'keyword_research', 'ai_task'],
|
||||
created_at__gte=last_30_days,
|
||||
amount__lt=0
|
||||
).aggregate(total=Sum('amount'))['total'] or 0)
|
||||
@@ -533,18 +604,20 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
status__in=['completed', 'succeeded']
|
||||
).order_by('-processed_at')[:5]
|
||||
|
||||
recent_activity = [
|
||||
{
|
||||
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': pay.account.name,
|
||||
'account_name': account_name,
|
||||
'amount': str(pay.amount),
|
||||
'currency': pay.currency,
|
||||
'timestamp': pay.processed_at.isoformat(),
|
||||
'description': f'Payment received via {pay.payment_method}'
|
||||
}
|
||||
for pay in recent_payments
|
||||
]
|
||||
'currency': currency,
|
||||
'timestamp': ts,
|
||||
'description': f'Payment received via {pay.payment_method or "unknown"}'
|
||||
})
|
||||
|
||||
return Response({
|
||||
'total_accounts': total_accounts,
|
||||
|
||||
@@ -3,7 +3,7 @@ Billing Module Admin
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from igny8_core.admin.base import AccountAdminMixin
|
||||
from .models import CreditTransaction, CreditUsageLog
|
||||
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
|
||||
|
||||
|
||||
@admin.register(CreditTransaction)
|
||||
@@ -41,3 +41,32 @@ class CreditUsageLogAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
return '-'
|
||||
get_account_display.short_description = 'Account'
|
||||
|
||||
|
||||
@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',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-05 17:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0005_credittransaction_reference_id_invoice_billing_email_and_more'),
|
||||
('igny8_core_auth', '0004_add_invoice_payment_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccountPaymentMethod',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('type', models.CharField(choices=[('stripe', 'Stripe'), ('paypal', 'PayPal'), ('bank_transfer', 'Bank Transfer'), ('local_wallet', 'Local Wallet')], db_index=True, max_length=50)),
|
||||
('display_name', models.CharField(default='', help_text='User-visible label', max_length=100)),
|
||||
('is_default', models.BooleanField(db_index=True, default=False)),
|
||||
('is_enabled', models.BooleanField(db_index=True, default=True)),
|
||||
('is_verified', models.BooleanField(db_index=True, default=False)),
|
||||
('country_code', models.CharField(blank=True, default='', help_text='ISO-2 country code (optional)', max_length=2)),
|
||||
('instructions', models.TextField(blank=True, default='')),
|
||||
('metadata', models.JSONField(blank=True, default=dict, help_text='Provider references or display metadata')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_account_payment_methods',
|
||||
'ordering': ['-is_default', 'display_name', 'id'],
|
||||
'indexes': [models.Index(fields=['account', 'is_default'], name='igny8_accou_tenant__30d459_idx'), models.Index(fields=['account', 'type'], name='igny8_accou_tenant__4cc9c7_idx')],
|
||||
'unique_together': {('account', 'display_name')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
# Backward compatibility aliases - models moved to business/billing/
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, CreditCostConfig
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, CreditCostConfig, AccountPaymentMethod
|
||||
|
||||
__all__ = ['CreditTransaction', 'CreditUsageLog', 'CreditCostConfig']
|
||||
__all__ = ['CreditTransaction', 'CreditUsageLog', 'CreditCostConfig', 'AccountPaymentMethod']
|
||||
|
||||
@@ -1610,7 +1610,8 @@ class ContentTaxonomyViewSet(SiteSectorModelViewSet):
|
||||
ordering = ['taxonomy_type', 'name']
|
||||
|
||||
# Filter configuration
|
||||
filterset_fields = ['taxonomy_type', 'sync_status', 'parent', 'external_id', 'external_taxonomy']
|
||||
# Removed "parent" to avoid non-model field in filterset (breaks drf-spectacular)
|
||||
filterset_fields = ['taxonomy_type', 'sync_status', 'external_id', 'external_taxonomy']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create taxonomy with site/sector context"""
|
||||
|
||||
@@ -1498,7 +1498,7 @@ class ContentTaxonomyViewSet(SiteSectorModelViewSet):
|
||||
ordering = ['taxonomy_type', 'name']
|
||||
|
||||
# Filter configuration
|
||||
filterset_fields = ['taxonomy_type', 'sync_status', 'parent', 'external_id', 'external_taxonomy']
|
||||
filterset_fields = ['taxonomy_type', 'sync_status', 'external_id', 'external_taxonomy']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create taxonomy with site/sector context"""
|
||||
|
||||
Reference in New Issue
Block a user