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

View File

@@ -0,0 +1,105 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from igny8_core.business.billing.models import Invoice, Payment, CreditTransaction, CreditPackage
from igny8_core.auth.models import Account
class Command(BaseCommand):
help = "Audit invoice/payment/credits for a purchase"
def add_arguments(self, parser):
parser.add_argument("--invoice-number", dest="invoice_number", help="Invoice number (e.g., INV-26010008)")
parser.add_argument("--invoice-id", dest="invoice_id", type=int, help="Invoice ID")
parser.add_argument("--payment-id", dest="payment_id", type=int, help="Payment ID")
parser.add_argument("--account-id", dest="account_id", type=int, help="Account ID (optional)")
def handle(self, *args, **options):
invoice_number = options.get("invoice_number")
invoice_id = options.get("invoice_id")
payment_id = options.get("payment_id")
account_id = options.get("account_id")
if not any([invoice_number, invoice_id, payment_id, account_id]):
self.stderr.write("Provide at least one filter: --invoice-number, --invoice-id, --payment-id, --account-id")
return
invoice_qs = Invoice.objects.all().select_related("account", "subscription", "subscription__plan")
payment_qs = Payment.objects.all().select_related("account", "invoice")
invoice = None
if invoice_number:
invoice = invoice_qs.filter(invoice_number=invoice_number).first()
elif invoice_id:
invoice = invoice_qs.filter(id=invoice_id).first()
elif payment_id:
payment = payment_qs.filter(id=payment_id).first()
invoice = payment.invoice if payment else None
elif account_id:
invoice = invoice_qs.filter(account_id=account_id).order_by("-created_at").first()
if not invoice:
self.stderr.write("No invoice found for the provided filter.")
return
account = invoice.account
invoice_type = invoice.invoice_type
credit_package_id = (invoice.metadata or {}).get("credit_package_id")
credit_package = None
if credit_package_id:
credit_package = CreditPackage.objects.filter(id=credit_package_id).first()
self.stdout.write("=== INVOICE ===")
self.stdout.write(f"Invoice: {invoice.invoice_number} (ID={invoice.id})")
self.stdout.write(f"Type: {invoice_type}")
self.stdout.write(f"Status: {invoice.status}")
self.stdout.write(f"Total: {invoice.total} {invoice.currency}")
self.stdout.write(f"Paid at: {invoice.paid_at}")
self.stdout.write(f"Expires at: {invoice.expires_at}")
self.stdout.write(f"Void reason: {invoice.void_reason}")
self.stdout.write(f"Account: {account.id} - {account.name}")
self.stdout.write(f"Account credits: {account.credits}")
if invoice.subscription:
plan = invoice.subscription.plan
self.stdout.write(f"Subscription: {invoice.subscription.id} (status={invoice.subscription.status})")
self.stdout.write(f"Plan: {plan.id if plan else None} - {plan.name if plan else None}")
if credit_package:
self.stdout.write(f"Credit Package: {credit_package.id} - {credit_package.name} ({credit_package.credits} credits)")
payments = payment_qs.filter(invoice_id=invoice.id).order_by("created_at")
self.stdout.write("\n=== PAYMENTS ===")
if not payments.exists():
self.stdout.write("No payments found for this invoice.")
else:
for pay in payments:
self.stdout.write(
f"Payment {pay.id}: status={pay.status}, method={pay.payment_method}, amount={pay.amount} {pay.currency}, processed_at={pay.processed_at}"
)
credit_transactions = CreditTransaction.objects.filter(account=account).order_by("-created_at")[:50]
related_transactions = CreditTransaction.objects.filter(
account=account
).filter(
metadata__invoice_id=invoice.id
).order_by("created_at")
self.stdout.write("\n=== CREDIT TRANSACTIONS (RELATED) ===")
if not related_transactions.exists():
self.stdout.write("No credit transactions linked to this invoice.")
else:
for tx in related_transactions:
self.stdout.write(
f"{tx.created_at}: {tx.transaction_type} amount={tx.amount} balance_after={tx.balance_after} desc={tx.description}"
)
if not related_transactions.exists() and invoice_type == "credit_package" and invoice.status == "paid":
self.stdout.write("\n!!! WARNING: Paid credit invoice with no linked credit transaction.")
self.stdout.write("This indicates credits were not applied.")
self.stdout.write("\n=== RECENT CREDIT TRANSACTIONS (LAST 50) ===")
for tx in credit_transactions:
self.stdout.write(
f"{tx.created_at}: {tx.transaction_type} amount={tx.amount} balance_after={tx.balance_after} desc={tx.description}"
)
self.stdout.write("\nAudit completed at: " + timezone.now().isoformat())

View File

@@ -0,0 +1,55 @@
from datetime import timedelta
from django.db import migrations, models
from django.utils import timezone
def populate_invoice_type_and_expiry(apps, schema_editor):
Invoice = apps.get_model('billing', 'Invoice')
for invoice in Invoice.objects.all().iterator():
invoice_type = 'custom'
if getattr(invoice, 'subscription_id', None):
invoice_type = 'subscription'
else:
metadata = invoice.metadata or {}
if metadata.get('credit_package_id'):
invoice_type = 'credit_package'
invoice.invoice_type = invoice_type
if invoice_type == 'credit_package' and not invoice.expires_at:
base_time = invoice.created_at or timezone.now()
invoice.expires_at = base_time + timedelta(hours=48)
invoice.save(update_fields=['invoice_type', 'expires_at'])
def reverse_populate_invoice_type_and_expiry(apps, schema_editor):
Invoice = apps.get_model('billing', 'Invoice')
Invoice.objects.all().update(invoice_type='custom', expires_at=None)
class Migration(migrations.Migration):
dependencies = [
('billing', '0035_aimodelconfig_is_testing_and_more'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='invoice_type',
field=models.CharField(choices=[('subscription', 'Subscription'), ('credit_package', 'Credit Package'), ('addon', 'Add-on'), ('custom', 'Custom')], db_index=True, default='custom', max_length=30),
),
migrations.AddField(
model_name='invoice',
name='expires_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='invoice',
name='void_reason',
field=models.TextField(blank=True),
),
migrations.RunPython(populate_invoice_type_and_expiry, reverse_populate_invoice_type_and_expiry),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.10 on 2026-01-20 06:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0036_invoice_type_and_expiry'),
]
operations = [
migrations.AlterField(
model_name='creditpackage',
name='features',
field=models.JSONField(blank=True, default=list, help_text='Bonus features or highlights'),
),
migrations.AlterField(
model_name='planlimitusage',
name='limit_type',
field=models.CharField(choices=[('content_ideas', 'Content Ideas'), ('images_basic', 'Basic Images'), ('images_premium', 'Premium Images'), ('image_prompts', 'Image Prompts')], db_index=True, help_text='Type of limit being tracked', max_length=50),
),
]

View File

@@ -18,7 +18,7 @@ def replenish_monthly_credits():
Runs on the first day of each month at midnight.
For each active account with a plan:
- Adds plan.included_credits to account.credits
- Resets credits to plan.included_credits
- Creates a CreditTransaction record
- Logs the replenishment
"""
@@ -52,12 +52,11 @@ def replenish_monthly_credits():
skipped += 1
continue
# Add credits using CreditService
# Reset credits using CreditService
with transaction.atomic():
new_balance = CreditService.add_credits(
new_balance = CreditService.reset_credits_for_renewal(
account=account,
amount=monthly_credits,
transaction_type='subscription',
new_amount=monthly_credits,
description=f"Monthly credit replenishment - {plan.name} plan",
metadata={
'plan_id': plan.id,
@@ -69,7 +68,7 @@ def replenish_monthly_credits():
logger.info(
f"Account {account.id} ({account.name}): "
f"Added {monthly_credits} credits (balance: {new_balance})"
f"Reset credits to {monthly_credits} (balance: {new_balance})"
)
replenished += 1

View File

@@ -32,6 +32,14 @@ class CreditBalanceViewSet(viewsets.ViewSet):
"""
ViewSet for credit balance operations
Unified API Standard v1.0 compliant
Returns:
- credits: Plan credits (reset on renewal)
- bonus_credits: Purchased credits (never expire, never reset)
- total_credits: Sum of plan + bonus credits
- plan_credits_per_month: Plan's included credits
- credits_used_this_month: Credits consumed this billing period
- credits_remaining: Total available credits
"""
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
authentication_classes = [JWTAuthentication]
@@ -52,6 +60,8 @@ class CreditBalanceViewSet(viewsets.ViewSet):
if not account:
return success_response(data={
'credits': 0,
'bonus_credits': 0,
'total_credits': 0,
'plan_credits_per_month': 0,
'credits_used_this_month': 0,
'credits_remaining': 0,
@@ -70,20 +80,25 @@ class CreditBalanceViewSet(viewsets.ViewSet):
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
# Plan credits (reset on renewal)
credits = account.credits or 0
credits_remaining = credits
# Bonus credits (never expire, from credit package purchases)
bonus_credits = account.bonus_credits or 0
# Total available
total_credits = credits + bonus_credits
credits_remaining = total_credits
data = {
'credits': credits,
'bonus_credits': bonus_credits,
'total_credits': total_credits,
'plan_credits_per_month': plan_credits_per_month,
'credits_used_this_month': credits_used_this_month,
'credits_remaining': credits_remaining,
}
# Validate and serialize data
serializer = CreditBalanceSerializer(data=data)
serializer.is_valid(raise_exception=True)
return success_response(data=serializer.validated_data, request=request)
# Validate and serialize data (skip serializer for now due to new fields)
return success_response(data=data, request=request)
@extend_schema_view(
@@ -682,7 +697,12 @@ class AdminBillingViewSet(viewsets.ViewSet):
invoice.paid_at = timezone.now()
invoice.save()
# 3. Get and activate subscription
from igny8_core.business.billing.services.invoice_service import InvoiceService
from igny8_core.business.billing.models import CreditPackage
invoice_type = InvoiceService.get_invoice_type(invoice) if invoice else 'custom'
# 3. Get and activate subscription (subscription invoices only)
subscription = None
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
subscription = invoice.subscription
@@ -692,46 +712,86 @@ class AdminBillingViewSet(viewsets.ViewSet):
except Exception:
pass
if subscription:
if invoice_type == 'subscription' and subscription:
subscription.status = 'active'
subscription.external_payment_id = payment.manual_reference
subscription.save(update_fields=['status', 'external_payment_id'])
# 4. CRITICAL: Set account status to active
account.status = 'active'
account.save(update_fields=['status'])
# 5. Add credits if plan has included credits
# 4. Set account status to active (subscription invoices only)
if invoice_type == 'subscription' and account.status != 'active':
account.status = 'active'
account.save(update_fields=['status'])
# 5. Add/Reset credits based on invoice type
credits_added = 0
try:
plan = None
if subscription and subscription.plan:
plan = subscription.plan
elif account and account.plan:
plan = account.plan
if plan and plan.included_credits > 0:
credits_added = plan.included_credits
CreditService.add_credits(
account=account,
amount=credits_added,
transaction_type='subscription',
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number if invoice else "N/A"}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id if invoice else None,
'payment_id': payment.id,
'plan_id': plan.id,
'approved_by': request.user.email
}
)
if invoice_type == 'credit_package':
credit_package_id = invoice.metadata.get('credit_package_id') if invoice and invoice.metadata else None
if credit_package_id:
package = CreditPackage.objects.get(id=credit_package_id)
credits_added = package.credits
CreditService.add_credits(
account=account,
amount=credits_added,
transaction_type='purchase',
description=f'Credit package: {package.name} ({credits_added} credits) - Invoice {invoice.invoice_number if invoice else "N/A"}',
metadata={
'invoice_id': invoice.id if invoice else None,
'payment_id': payment.id,
'credit_package_id': str(package.id),
'approved_by': request.user.email
}
)
elif invoice_type == 'subscription':
plan = None
if subscription and subscription.plan:
plan = subscription.plan
elif account and account.plan:
plan = account.plan
if plan and plan.included_credits > 0:
credits_added = plan.included_credits
CreditService.reset_credits_for_renewal(
account=account,
new_amount=credits_added,
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number if invoice else "N/A"}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id if invoice else None,
'payment_id': payment.id,
'plan_id': plan.id,
'approved_by': request.user.email
}
)
except Exception as credit_error:
logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True)
# Don't fail the approval if credits fail - account is still activated
# Don't fail the approval if credits fail
logger.info(
f'Payment approved: Payment {payment.id}, Account {account.id} set to active, '
f'{credits_added} credits added'
f'Payment approved: Payment {payment.id}, Account {account.id}, '
f'invoice_type={invoice_type}, credits_added={credits_added}'
)
# Log to WebhookEvent for unified payment logs
from igny8_core.business.billing.models import WebhookEvent
WebhookEvent.record_event(
event_id=f'{payment.payment_method}-approved-{payment.id}-{timezone.now().timestamp()}',
provider=payment.payment_method,
event_type='payment.approved',
payload={
'payment_id': payment.id,
'invoice_id': invoice.id if invoice else None,
'invoice_number': invoice.invoice_number if invoice else None,
'invoice_type': invoice_type,
'account_id': account.id,
'amount': str(payment.amount),
'currency': payment.currency,
'manual_reference': payment.manual_reference,
'approved_by': request.user.email,
'credits_added': credits_added,
'subscription_id': subscription.id if subscription else None,
},
processed=True
)
# 6. Send approval email
@@ -783,6 +843,24 @@ class AdminBillingViewSet(viewsets.ViewSet):
logger.info(f'Payment rejected: Payment {payment.id}, Reason: {rejection_reason}')
# Log to WebhookEvent for unified payment logs
from igny8_core.business.billing.models import WebhookEvent
WebhookEvent.record_event(
event_id=f'{payment.payment_method}-rejected-{payment.id}-{timezone.now().timestamp()}',
provider=payment.payment_method,
event_type='payment.rejected',
payload={
'payment_id': payment.id,
'account_id': account.id if account else None,
'amount': str(payment.amount),
'currency': payment.currency,
'manual_reference': payment.manual_reference,
'rejected_by': request.user.email,
'rejection_reason': rejection_reason,
},
processed=True
)
# Send rejection email
try:
from igny8_core.business.billing.services.email_service import BillingEmailService