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
|
||||
|
||||
@@ -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())
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user