payemnt billing and credits refactoring
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user