payemnt billing and credits refactoring

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-20 07:39:51 +00:00
parent a97c72640a
commit bc50b022f1
34 changed files with 3028 additions and 307 deletions

View File

@@ -16,12 +16,35 @@ from igny8_core.business.billing.models import (
PaymentMethodConfig,
PlanLimitUsage,
AIModelConfig,
WebhookEvent,
)
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
def _get_invoice_type(invoice):
if invoice and getattr(invoice, 'invoice_type', None):
return invoice.invoice_type
if invoice and invoice.metadata and invoice.metadata.get('credit_package_id'):
return 'credit_package'
if invoice and getattr(invoice, 'subscription_id', None):
return 'subscription'
return 'custom'
def _get_credit_package(invoice):
if not invoice or not invoice.metadata:
return None
credit_package_id = invoice.metadata.get('credit_package_id')
if not credit_package_id:
return None
try:
return CreditPackage.objects.get(id=credit_package_id)
except CreditPackage.DoesNotExist:
return None
class CreditTransactionResource(resources.ModelResource):
"""Resource class for exporting Credit Transactions"""
class Meta:
@@ -272,87 +295,111 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
invoice.status = 'paid'
invoice.paid_at = timezone.now()
invoice.save()
# Update Subscription
if subscription and subscription.status != 'active':
invoice_type = _get_invoice_type(invoice)
# Update Subscription (subscription invoices only)
if invoice_type == 'subscription' and subscription and subscription.status != 'active':
subscription.status = 'active'
subscription.external_payment_id = obj.manual_reference
subscription.save()
# Update Account
if account.status != 'active':
# Update Account (subscription invoices only)
if invoice_type == 'subscription' and account.status != 'active':
account.status = 'active'
account.save()
# Add or Reset Credits (check if not already added)
from igny8_core.business.billing.models import CreditTransaction, Invoice
existing_credit = CreditTransaction.objects.filter(
account=account,
metadata__payment_id=obj.id
).exists()
if not existing_credit:
credits_to_add = 0
plan_name = ''
is_renewal = False
if subscription and subscription.plan:
credits_to_add = subscription.plan.included_credits
plan_name = subscription.plan.name
# Check if this is a renewal (previous paid invoices exist)
previous_paid = Invoice.objects.filter(
subscription=subscription,
status='paid'
).exclude(id=invoice.id if invoice else None).exists()
is_renewal = previous_paid
elif account and account.plan:
credits_to_add = account.plan.included_credits
plan_name = account.plan.name
# Check renewal by account history
is_renewal = CreditTransaction.objects.filter(
account=account,
transaction_type='subscription'
).exists()
if credits_to_add > 0:
if is_renewal:
# Renewal: Reset credits to full plan amount
CreditService.reset_credits_for_renewal(
account=account,
new_amount=credits_to_add,
description=f'{plan_name} Renewal - 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,
'is_renewal': True
}
)
self.message_user(
request,
f'✓ Renewal approved: Account activated, credits reset to {credits_to_add}',
level='SUCCESS'
)
else:
# Initial: Add credits
CreditService.add_credits(
if invoice_type == 'credit_package':
package = _get_credit_package(invoice)
if package:
credits_to_add = package.credits
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
CreditService.add_bonus_credits(
account=account,
amount=credits_to_add,
transaction_type='subscription',
description=f'{plan_name} - Invoice {invoice.invoice_number}',
description=f'Credit package: {package.name} ({credits_to_add} bonus credits) - Invoice {invoice.invoice_number}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id,
'payment_id': obj.id,
'credit_package_id': str(package.id),
'approved_by': request.user.email
}
)
self.message_user(
request,
f'Payment approved: Account activated, {credits_to_add} credits added',
request,
f'Credit package approved: {credits_to_add} bonus credits added (never expire)',
level='SUCCESS'
)
else:
if subscription and subscription.plan:
credits_to_add = subscription.plan.included_credits
plan_name = subscription.plan.name
# Check if this is a renewal (previous paid invoices exist)
previous_paid = Invoice.objects.filter(
subscription=subscription,
status='paid'
).exclude(id=invoice.id if invoice else None).exists()
is_renewal = previous_paid
elif account and account.plan:
credits_to_add = account.plan.included_credits
plan_name = account.plan.name
# Check renewal by account history
is_renewal = CreditTransaction.objects.filter(
account=account,
transaction_type='subscription'
).exists()
if credits_to_add > 0:
if is_renewal:
# Renewal: Reset credits to full plan amount
CreditService.reset_credits_for_renewal(
account=account,
new_amount=credits_to_add,
description=f'{plan_name} Renewal - 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,
'is_renewal': True
}
)
self.message_user(
request,
f'✓ Renewal approved: Account activated, credits reset to {credits_to_add}',
level='SUCCESS'
)
else:
# Initial: Add credits
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(
@@ -400,15 +447,18 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
invoice.paid_at = timezone.now()
invoice.save()
# Update Subscription
if subscription:
invoice_type = _get_invoice_type(invoice)
# Update Subscription (subscription invoices only)
if invoice_type == 'subscription' and subscription:
subscription.status = 'active'
subscription.external_payment_id = payment.manual_reference
subscription.save()
# Update Account
account.status = 'active'
account.save()
# Update Account (subscription invoices only)
if invoice_type == 'subscription' and account.status != 'active':
account.status = 'active'
account.save()
# Add or Reset Credits based on whether this is a renewal
# Check if there are previous paid invoices for this subscription (renewal)
@@ -420,9 +470,25 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
status='paid'
).exclude(id=invoice.id).exists()
is_renewal = previous_paid_invoices
credits_added = 0
if subscription and subscription.plan and subscription.plan.included_credits > 0:
if invoice_type == 'credit_package':
package = _get_credit_package(invoice)
if package:
credits_added = package.credits
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
CreditService.add_bonus_credits(
account=account,
amount=credits_added,
description=f'Credit package: {package.name} ({credits_added} bonus credits) - Invoice {invoice.invoice_number}',
metadata={
'invoice_id': invoice.id,
'payment_id': payment.id,
'credit_package_id': str(package.id),
'approved_by': request.user.email
}
)
elif subscription and subscription.plan and subscription.plan.included_credits > 0:
credits_added = subscription.plan.included_credits
if is_renewal:
# Renewal: Reset credits to full plan amount
@@ -1050,3 +1116,167 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
count = queryset.update(is_testing=False)
self.message_user(request, f'{count} model(s) unmarked as testing.', messages.SUCCESS)
unset_testing.short_description = 'Unset testing flag'
class WebhookEventResource(resources.ModelResource):
"""Resource class for exporting Webhook Events"""
class Meta:
model = WebhookEvent
fields = ('id', 'event_id', 'provider', 'event_type', 'processed', 'processed_at',
'error_message', 'retry_count', 'created_at')
export_order = fields
@admin.register(WebhookEvent)
class WebhookEventAdmin(ExportMixin, Igny8ModelAdmin):
"""
Payment Logs Admin - Centralized view of all payment webhook events
Shows Stripe and PayPal payment events with processing status
"""
resource_class = WebhookEventResource
list_display = [
'event_id_short',
'provider_badge',
'event_type_display',
'processed_badge',
'processing_time',
'retry_count',
'created_at',
]
list_filter = ['provider', 'event_type', 'processed', 'created_at']
search_fields = ['event_id', 'event_type', 'error_message']
readonly_fields = ['event_id', 'provider', 'event_type', 'payload_formatted', 'processed',
'processed_at', 'error_message', 'retry_count', 'created_at']
date_hierarchy = 'created_at'
ordering = ['-created_at']
fieldsets = (
('Event Info', {
'fields': ('event_id', 'provider', 'event_type', 'created_at')
}),
('Processing Status', {
'fields': ('processed', 'processed_at', 'retry_count', 'error_message'),
}),
('Payload', {
'fields': ('payload_formatted',),
'classes': ('collapse',),
}),
)
actions = ['mark_as_processed', 'retry_processing']
def event_id_short(self, obj):
"""Show truncated event ID"""
return obj.event_id[:30] + '...' if len(obj.event_id) > 30 else obj.event_id
event_id_short.short_description = 'Event ID'
def provider_badge(self, obj):
"""Show provider as a colored badge"""
colors = {
'stripe': '#635BFF', # Stripe purple
'paypal': '#003087', # PayPal blue
}
color = colors.get(obj.provider, '#666')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px; font-weight: bold;">{}</span>',
color,
obj.provider.upper()
)
provider_badge.short_description = 'Provider'
def event_type_display(self, obj):
"""Show event type with friendly formatting"""
# Map common event types to friendly names
friendly_names = {
'checkout.session.completed': 'Checkout Completed',
'payment_intent.succeeded': 'Payment Succeeded',
'payment_intent.payment_failed': 'Payment Failed',
'invoice.paid': 'Invoice Paid',
'invoice.payment_failed': 'Invoice Payment Failed',
'customer.subscription.created': 'Subscription Created',
'customer.subscription.updated': 'Subscription Updated',
'customer.subscription.deleted': 'Subscription Cancelled',
'PAYMENT.CAPTURE.COMPLETED': 'Payment Captured',
'PAYMENT.CAPTURE.DENIED': 'Payment Denied',
'PAYMENT.CAPTURE.REFUNDED': 'Payment Refunded',
'BILLING.SUBSCRIPTION.ACTIVATED': 'Subscription Activated',
'BILLING.SUBSCRIPTION.CANCELLED': 'Subscription Cancelled',
}
friendly = friendly_names.get(obj.event_type, obj.event_type)
return format_html('<span title="{}">{}</span>', obj.event_type, friendly)
event_type_display.short_description = 'Event Type'
def processed_badge(self, obj):
"""Show processing status as badge"""
if obj.processed:
return format_html(
'<span style="background-color: #10B981; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px;">✓ Processed</span>'
)
elif obj.error_message:
return format_html(
'<span style="background-color: #EF4444; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px;">✗ Failed</span>'
)
else:
return format_html(
'<span style="background-color: #F59E0B; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px;">⏳ Pending</span>'
)
processed_badge.short_description = 'Status'
def processing_time(self, obj):
"""Show time taken to process"""
if obj.processed and obj.processed_at:
delta = obj.processed_at - obj.created_at
ms = delta.total_seconds() * 1000
if ms < 1000:
return f'{ms:.0f}ms'
return f'{delta.total_seconds():.1f}s'
return '-'
processing_time.short_description = 'Process Time'
def payload_formatted(self, obj):
"""Show formatted JSON payload"""
import json
try:
formatted = json.dumps(obj.payload, indent=2)
return format_html(
'<pre style="max-height: 400px; overflow: auto; background: #f5f5f5; '
'padding: 10px; border-radius: 4px; font-size: 12px;">{}</pre>',
formatted
)
except:
return str(obj.payload)
payload_formatted.short_description = 'Payload'
def mark_as_processed(self, request, queryset):
"""Mark selected events as processed"""
from django.utils import timezone
count = queryset.update(processed=True, processed_at=timezone.now())
self.message_user(request, f'{count} event(s) marked as processed.', messages.SUCCESS)
mark_as_processed.short_description = 'Mark as processed'
def retry_processing(self, request, queryset):
"""Queue selected events for reprocessing"""
count = 0
for event in queryset.filter(processed=False):
# TODO: Implement actual reprocessing logic based on event type
event.retry_count += 1
event.save()
count += 1
self.message_user(
request,
f'{count} event(s) queued for reprocessing. (Manual reprocessing required)',
messages.INFO
)
retry_processing.short_description = 'Retry processing'
def has_add_permission(self, request):
"""Webhook events should only be created by webhooks"""
return False
def has_change_permission(self, request, obj=None):
"""Allow viewing but restrict editing"""
return False