payemnt billing and credits refactoring
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-01-20 06:11
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0034_add_user_phone'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='bonus_credits',
|
||||||
|
field=models.IntegerField(default=0, help_text='Purchased/bonus credits (never expire, never reset)', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='bonus_credits',
|
||||||
|
field=models.IntegerField(default=0, help_text='Purchased/bonus credits (never expire, never reset)', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='credits',
|
||||||
|
field=models.IntegerField(default=0, help_text='Plan credits (reset on renewal)', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='credits',
|
||||||
|
field=models.IntegerField(default=0, help_text='Plan credits (reset on renewal)', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -83,7 +83,8 @@ class Account(SoftDeletableModel):
|
|||||||
)
|
)
|
||||||
stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)
|
stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts')
|
plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts')
|
||||||
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Plan credits (reset on renewal)")
|
||||||
|
bonus_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Purchased/bonus credits (never expire, never reset)")
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
|
||||||
payment_method = models.CharField(
|
payment_method = models.CharField(
|
||||||
max_length=30,
|
max_length=30,
|
||||||
@@ -143,6 +144,11 @@ class Account(SoftDeletableModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_credits(self):
|
||||||
|
"""Total available credits (plan + bonus). Use this for balance checks."""
|
||||||
|
return self.credits + self.bonus_credits
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default_payment_method(self):
|
def default_payment_method(self):
|
||||||
"""Get default payment method from AccountPaymentMethod table"""
|
"""Get default payment method from AccountPaymentMethod table"""
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||||||
plan = PlanSerializer(read_only=True)
|
plan = PlanSerializer(read_only=True)
|
||||||
plan_id = serializers.PrimaryKeyRelatedField(queryset=Plan.objects.filter(is_active=True), write_only=True, source='plan', required=False)
|
plan_id = serializers.PrimaryKeyRelatedField(queryset=Plan.objects.filter(is_active=True), write_only=True, source='plan', required=False)
|
||||||
subscription = SubscriptionSerializer(read_only=True, allow_null=True)
|
subscription = SubscriptionSerializer(read_only=True, allow_null=True)
|
||||||
|
total_credits = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_total_credits(self, obj):
|
||||||
|
"""Return total available credits (plan + bonus)."""
|
||||||
|
return obj.credits + obj.bonus_credits
|
||||||
|
|
||||||
def validate_plan_id(self, value):
|
def validate_plan_id(self, value):
|
||||||
"""Validate plan_id is provided during creation."""
|
"""Validate plan_id is provided during creation."""
|
||||||
@@ -52,7 +57,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||||||
model = Account
|
model = Account
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
|
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
|
||||||
'credits', 'status', 'payment_method',
|
'credits', 'bonus_credits', 'total_credits', 'status', 'payment_method',
|
||||||
'subscription', 'billing_country',
|
'subscription', 'billing_country',
|
||||||
'account_timezone', 'timezone_mode', 'timezone_offset',
|
'account_timezone', 'timezone_mode', 'timezone_offset',
|
||||||
'created_at'
|
'created_at'
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
('automation', '0012_add_publishing_image_defaults'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name='automationconfig',
|
||||||
|
new_name='igny8_autom_test_mo_f43497_idx',
|
||||||
|
old_name='automation__test_mo_idx',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='automationrun',
|
||||||
|
name='trigger_type',
|
||||||
|
field=models.CharField(choices=[('manual', 'Manual'), ('scheduled', 'Scheduled'), ('test', 'Test')], max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -19,7 +19,7 @@ from igny8_core.business.billing.services.credit_service import CreditService
|
|||||||
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||||
from igny8_core.business.billing.models import (
|
from igny8_core.business.billing.models import (
|
||||||
CreditTransaction, Invoice, Payment, CreditPackage,
|
CreditTransaction, Invoice, Payment, CreditPackage,
|
||||||
AccountPaymentMethod, PaymentMethodConfig
|
AccountPaymentMethod, PaymentMethodConfig, WebhookEvent
|
||||||
)
|
)
|
||||||
from igny8_core.modules.billing.serializers import PaymentMethodConfigSerializer, PaymentConfirmationSerializer
|
from igny8_core.modules.billing.serializers import PaymentMethodConfigSerializer, PaymentConfirmationSerializer
|
||||||
import logging
|
import logging
|
||||||
@@ -151,6 +151,27 @@ class BillingViewSet(viewsets.GenericViewSet):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Log to WebhookEvent for unified payment logs
|
||||||
|
WebhookEvent.record_event(
|
||||||
|
event_id=f'bt-confirm-{external_payment_id}-{timezone.now().timestamp()}',
|
||||||
|
provider='bank_transfer',
|
||||||
|
event_type='payment.confirmed',
|
||||||
|
payload={
|
||||||
|
'external_payment_id': external_payment_id,
|
||||||
|
'account_id': account.id,
|
||||||
|
'subscription_id': subscription.id,
|
||||||
|
'amount': str(amount),
|
||||||
|
'currency': 'USD',
|
||||||
|
'payer_name': payer_name,
|
||||||
|
'proof_url': proof_url if proof_url else '',
|
||||||
|
'period_months': period_months,
|
||||||
|
'confirmed_by': request.user.email,
|
||||||
|
'plan_name': account.plan.name if account.plan else None,
|
||||||
|
'credits_added': monthly_credits,
|
||||||
|
},
|
||||||
|
processed=True
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Bank transfer confirmed for account {account.id}: '
|
f'Bank transfer confirmed for account {account.id}: '
|
||||||
f'{external_payment_id}, {amount}, {monthly_credits} credits added'
|
f'{external_payment_id}, {amount}, {monthly_credits} credits added'
|
||||||
@@ -307,6 +328,26 @@ class BillingViewSet(viewsets.GenericViewSet):
|
|||||||
metadata={'proof_url': proof_url, 'submitted_by': request.user.email} if proof_url else {'submitted_by': request.user.email}
|
metadata={'proof_url': proof_url, 'submitted_by': request.user.email} if proof_url else {'submitted_by': request.user.email}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Log to WebhookEvent for unified payment logs
|
||||||
|
WebhookEvent.record_event(
|
||||||
|
event_id=f'{payment_method}-submit-{payment.id}-{timezone.now().timestamp()}',
|
||||||
|
provider=payment_method, # 'bank_transfer', 'local_wallet', 'manual'
|
||||||
|
event_type='payment.submitted',
|
||||||
|
payload={
|
||||||
|
'payment_id': payment.id,
|
||||||
|
'invoice_id': invoice.id,
|
||||||
|
'invoice_number': invoice.invoice_number,
|
||||||
|
'account_id': request.account.id,
|
||||||
|
'amount': str(amount),
|
||||||
|
'currency': invoice.currency,
|
||||||
|
'manual_reference': manual_reference,
|
||||||
|
'manual_notes': manual_notes,
|
||||||
|
'proof_url': proof_url if proof_url else '',
|
||||||
|
'submitted_by': request.user.email,
|
||||||
|
},
|
||||||
|
processed=False # Will be marked processed when approved
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Payment confirmation submitted: Payment {payment.id}, '
|
f'Payment confirmation submitted: Payment {payment.id}, '
|
||||||
f'Invoice {invoice.invoice_number}, Account {request.account.id}, '
|
f'Invoice {invoice.invoice_number}, Account {request.account.id}, '
|
||||||
@@ -442,50 +483,63 @@ class BillingViewSet(viewsets.GenericViewSet):
|
|||||||
invoice.paid_at = timezone.now()
|
invoice.paid_at = timezone.now()
|
||||||
invoice.save(update_fields=['status', 'paid_at'])
|
invoice.save(update_fields=['status', 'paid_at'])
|
||||||
|
|
||||||
# 3. Update Subscription
|
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||||
if subscription:
|
from igny8_core.business.billing.models import CreditPackage
|
||||||
|
|
||||||
|
invoice_type = InvoiceService.get_invoice_type(invoice) if invoice else 'custom'
|
||||||
|
|
||||||
|
# 3. Update Subscription (subscription invoices only)
|
||||||
|
if invoice_type == 'subscription' and subscription:
|
||||||
subscription.status = 'active'
|
subscription.status = 'active'
|
||||||
subscription.external_payment_id = payment.manual_reference
|
subscription.external_payment_id = payment.manual_reference
|
||||||
subscription.save(update_fields=['status', 'external_payment_id'])
|
subscription.save(update_fields=['status', 'external_payment_id'])
|
||||||
|
|
||||||
# 4. Update Account
|
# 4. Update Account status (subscription invoices only)
|
||||||
|
if invoice_type == 'subscription' and account.status != 'active':
|
||||||
account.status = 'active'
|
account.status = 'active'
|
||||||
account.save(update_fields=['status'])
|
account.save(update_fields=['status'])
|
||||||
|
|
||||||
# 5. Add Credits (if subscription has plan)
|
# 5. Add/Reset Credits based on invoice type
|
||||||
credits_added = 0
|
credits_added = 0
|
||||||
try:
|
try:
|
||||||
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
if invoice_type == 'credit_package':
|
||||||
credits_added = subscription.plan.included_credits
|
credit_package_id = invoice.metadata.get('credit_package_id') if invoice and invoice.metadata else None
|
||||||
|
if credit_package_id:
|
||||||
# Use CreditService to add credits
|
package = CreditPackage.objects.get(id=credit_package_id)
|
||||||
CreditService.add_credits(
|
credits_added = package.credits
|
||||||
|
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
|
||||||
|
CreditService.add_bonus_credits(
|
||||||
account=account,
|
account=account,
|
||||||
amount=credits_added,
|
amount=credits_added,
|
||||||
transaction_type='subscription',
|
description=f'Credit package: {package.name} ({credits_added} bonus credits) - Invoice {invoice.invoice_number}',
|
||||||
description=f'{subscription.plan.name} plan credits - Invoice {invoice.invoice_number}',
|
|
||||||
metadata={
|
metadata={
|
||||||
'subscription_id': subscription.id,
|
|
||||||
'invoice_id': invoice.id,
|
'invoice_id': invoice.id,
|
||||||
'payment_id': payment.id,
|
'payment_id': payment.id,
|
||||||
'plan_id': subscription.plan.id,
|
'credit_package_id': str(package.id),
|
||||||
'approved_by': request.user.email
|
'approved_by': request.user.email,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif account and account.plan and account.plan.included_credits > 0:
|
else:
|
||||||
# Fallback: use account plan if subscription not found
|
raise Exception('Credit package ID missing on invoice metadata')
|
||||||
credits_added = account.plan.included_credits
|
elif invoice_type == 'subscription':
|
||||||
CreditService.add_credits(
|
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,
|
account=account,
|
||||||
amount=credits_added,
|
new_amount=credits_added,
|
||||||
transaction_type='subscription',
|
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number}',
|
||||||
description=f'{account.plan.name} plan credits - Invoice {invoice.invoice_number}',
|
|
||||||
metadata={
|
metadata={
|
||||||
|
'subscription_id': subscription.id if subscription else None,
|
||||||
'invoice_id': invoice.id,
|
'invoice_id': invoice.id,
|
||||||
'payment_id': payment.id,
|
'payment_id': payment.id,
|
||||||
'plan_id': account.plan.id,
|
'plan_id': plan.id,
|
||||||
'approved_by': request.user.email,
|
'approved_by': request.user.email
|
||||||
'fallback': 'account_plan'
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as credit_error:
|
except Exception as credit_error:
|
||||||
@@ -495,7 +549,7 @@ class BillingViewSet(viewsets.GenericViewSet):
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Payment approved: Payment {payment.id}, Invoice {invoice.invoice_number}, '
|
f'Payment approved: Payment {payment.id}, Invoice {invoice.invoice_number}, '
|
||||||
f'Account {account.id} activated, {credits_added} credits added'
|
f'Account {account.id}, invoice_type={invoice_type}, credits_added={credits_added}'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send activation email to user
|
# Send activation email to user
|
||||||
@@ -513,12 +567,13 @@ class BillingViewSet(viewsets.GenericViewSet):
|
|||||||
'account_id': account.id,
|
'account_id': account.id,
|
||||||
'account_status': account.status,
|
'account_status': account.status,
|
||||||
'subscription_status': subscription.status if subscription else None,
|
'subscription_status': subscription.status if subscription else None,
|
||||||
|
'invoice_type': invoice_type,
|
||||||
'credits_added': credits_added,
|
'credits_added': credits_added,
|
||||||
'total_credits': account.credits,
|
'total_credits': account.credits,
|
||||||
'approved_by': request.user.email,
|
'approved_by': request.user.email,
|
||||||
'approved_at': payment.approved_at.isoformat()
|
'approved_at': payment.approved_at.isoformat()
|
||||||
},
|
},
|
||||||
message='Payment approved successfully. Account activated.',
|
message='Payment approved successfully.',
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -651,6 +706,8 @@ class InvoiceViewSet(AccountModelViewSet):
|
|||||||
'id': invoice.id,
|
'id': invoice.id,
|
||||||
'invoice_number': invoice.invoice_number,
|
'invoice_number': invoice.invoice_number,
|
||||||
'status': invoice.status,
|
'status': invoice.status,
|
||||||
|
'invoice_type': invoice.invoice_type,
|
||||||
|
'credit_package_id': invoice.metadata.get('credit_package_id') if invoice.metadata else None,
|
||||||
'total': str(invoice.total), # Alias for compatibility
|
'total': str(invoice.total), # Alias for compatibility
|
||||||
'total_amount': str(invoice.total),
|
'total_amount': str(invoice.total),
|
||||||
'subtotal': str(invoice.subtotal),
|
'subtotal': str(invoice.subtotal),
|
||||||
@@ -659,6 +716,8 @@ class InvoiceViewSet(AccountModelViewSet):
|
|||||||
'invoice_date': invoice.invoice_date.isoformat(),
|
'invoice_date': invoice.invoice_date.isoformat(),
|
||||||
'due_date': invoice.due_date.isoformat(),
|
'due_date': invoice.due_date.isoformat(),
|
||||||
'paid_at': invoice.paid_at.isoformat() if invoice.paid_at else None,
|
'paid_at': invoice.paid_at.isoformat() if invoice.paid_at else None,
|
||||||
|
'expires_at': invoice.expires_at.isoformat() if invoice.expires_at else None,
|
||||||
|
'void_reason': invoice.void_reason,
|
||||||
'line_items': invoice.line_items,
|
'line_items': invoice.line_items,
|
||||||
'billing_email': invoice.billing_email,
|
'billing_email': invoice.billing_email,
|
||||||
'notes': invoice.notes,
|
'notes': invoice.notes,
|
||||||
@@ -732,6 +791,66 @@ class InvoiceViewSet(AccountModelViewSet):
|
|||||||
logger.error(f'PDF generation failed for invoice {pk}: {str(e)}', exc_info=True)
|
logger.error(f'PDF generation failed for invoice {pk}: {str(e)}', exc_info=True)
|
||||||
return error_response(error=f'Failed to generate PDF: {str(e)}', status_code=500, request=request)
|
return error_response(error=f'Failed to generate PDF: {str(e)}', status_code=500, request=request)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def cancel(self, request, pk=None):
|
||||||
|
"""Cancel a pending credit invoice (user action)."""
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||||
|
|
||||||
|
try:
|
||||||
|
invoice = self.get_queryset().select_related('account').get(pk=pk)
|
||||||
|
invoice_type = InvoiceService.get_invoice_type(invoice)
|
||||||
|
|
||||||
|
if invoice_type != 'credit_package':
|
||||||
|
return error_response(
|
||||||
|
error='Only credit package invoices can be cancelled.',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
if invoice.status != 'pending':
|
||||||
|
return error_response(
|
||||||
|
error=f'Invoice is not pending (current status: {invoice.status})',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
invoice.status = 'void'
|
||||||
|
invoice.void_reason = 'user_cancelled'
|
||||||
|
invoice.save(update_fields=['status', 'void_reason', 'updated_at'])
|
||||||
|
|
||||||
|
Payment.objects.filter(
|
||||||
|
invoice=invoice,
|
||||||
|
status='pending_approval'
|
||||||
|
).update(
|
||||||
|
status='failed',
|
||||||
|
failed_at=timezone.now(),
|
||||||
|
failure_reason='Invoice cancelled by user'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||||
|
BillingEmailService.send_credit_invoice_cancelled_email(invoice)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send credit invoice cancellation email: {str(e)}')
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={'invoice_id': invoice.id, 'status': invoice.status},
|
||||||
|
message='Invoice cancelled successfully.',
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Invoice.DoesNotExist:
|
||||||
|
return error_response(error='Invoice not found', status_code=404, request=request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error cancelling invoice {pk}: {str(e)}', exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
error='Failed to cancel invoice. Please try again.',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PaymentViewSet(AccountModelViewSet):
|
class PaymentViewSet(AccountModelViewSet):
|
||||||
"""ViewSet for user-facing payments"""
|
"""ViewSet for user-facing payments"""
|
||||||
@@ -942,8 +1061,9 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
# Check for existing pending invoice for this package
|
# Check for existing pending invoice for this package
|
||||||
existing_pending = Invoice.objects.filter(
|
existing_pending = Invoice.objects.filter(
|
||||||
account=account,
|
account=account,
|
||||||
status='pending',
|
status='pending'
|
||||||
metadata__credit_package_id=package.id
|
).filter(
|
||||||
|
Q(invoice_type='credit_package') | Q(metadata__credit_package_id=package.id)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing_pending:
|
if existing_pending:
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ AUTO_APPROVE_PAYMENTS = getattr(settings, 'AUTO_APPROVE_PAYMENTS', False)
|
|||||||
# Invoice due date offset (days)
|
# Invoice due date offset (days)
|
||||||
INVOICE_DUE_DATE_OFFSET = getattr(settings, 'INVOICE_DUE_DATE_OFFSET', 7)
|
INVOICE_DUE_DATE_OFFSET = getattr(settings, 'INVOICE_DUE_DATE_OFFSET', 7)
|
||||||
|
|
||||||
|
# Credit package invoice expiry (hours)
|
||||||
|
CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS = getattr(settings, 'CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS', 48)
|
||||||
|
|
||||||
# Grace period for payment (days)
|
# Grace period for payment (days)
|
||||||
PAYMENT_GRACE_PERIOD = getattr(settings, 'PAYMENT_GRACE_PERIOD', 7)
|
PAYMENT_GRACE_PERIOD = getattr(settings, 'PAYMENT_GRACE_PERIOD', 7)
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -336,6 +336,13 @@ class Invoice(AccountBaseModel):
|
|||||||
Invoice for subscription or credit purchases
|
Invoice for subscription or credit purchases
|
||||||
Tracks billing invoices with line items and payment status
|
Tracks billing invoices with line items and payment status
|
||||||
"""
|
"""
|
||||||
|
INVOICE_TYPE_CHOICES = [
|
||||||
|
('subscription', 'Subscription'),
|
||||||
|
('credit_package', 'Credit Package'),
|
||||||
|
('addon', 'Add-on'),
|
||||||
|
('custom', 'Custom'),
|
||||||
|
]
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('draft', 'Draft'),
|
('draft', 'Draft'),
|
||||||
('pending', 'Pending'),
|
('pending', 'Pending'),
|
||||||
@@ -346,6 +353,14 @@ class Invoice(AccountBaseModel):
|
|||||||
|
|
||||||
invoice_number = models.CharField(max_length=50, unique=True, db_index=True)
|
invoice_number = models.CharField(max_length=50, unique=True, db_index=True)
|
||||||
|
|
||||||
|
# Type
|
||||||
|
invoice_type = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=INVOICE_TYPE_CHOICES,
|
||||||
|
default='custom',
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Subscription relationship
|
# Subscription relationship
|
||||||
subscription = models.ForeignKey(
|
subscription = models.ForeignKey(
|
||||||
'igny8_core_auth.Subscription',
|
'igny8_core_auth.Subscription',
|
||||||
@@ -369,6 +384,10 @@ class Invoice(AccountBaseModel):
|
|||||||
invoice_date = models.DateField(db_index=True)
|
invoice_date = models.DateField(db_index=True)
|
||||||
due_date = models.DateField()
|
due_date = models.DateField()
|
||||||
paid_at = models.DateTimeField(null=True, blank=True)
|
paid_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Void metadata
|
||||||
|
void_reason = models.TextField(blank=True)
|
||||||
|
|
||||||
# Line items
|
# Line items
|
||||||
line_items = models.JSONField(default=list, help_text="Invoice line items: [{description, amount, quantity}]")
|
line_items = models.JSONField(default=list, help_text="Invoice line items: [{description, amount, quantity}]")
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ class CreditService:
|
|||||||
For token-based operations, this is an estimate check only.
|
For token-based operations, this is an estimate check only.
|
||||||
Actual deduction happens after AI call with real token usage.
|
Actual deduction happens after AI call with real token usage.
|
||||||
|
|
||||||
|
Uses total_credits (plan + bonus) for the check.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Account instance
|
account: Account instance
|
||||||
operation_type: Type of operation
|
operation_type: Type of operation
|
||||||
@@ -274,9 +276,11 @@ class CreditService:
|
|||||||
# Fallback to constants
|
# Fallback to constants
|
||||||
required = CREDIT_COSTS.get(operation_type, 1)
|
required = CREDIT_COSTS.get(operation_type, 1)
|
||||||
|
|
||||||
if account.credits < required:
|
# Use total_credits (plan + bonus)
|
||||||
|
total_available = account.credits + account.bonus_credits
|
||||||
|
if total_available < required:
|
||||||
raise InsufficientCreditsError(
|
raise InsufficientCreditsError(
|
||||||
f"Insufficient credits. Required: {required}, Available: {account.credits}"
|
f"Insufficient credits. Required: {required}, Available: {total_available}"
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -286,6 +290,8 @@ class CreditService:
|
|||||||
Legacy method to check credits for a known amount.
|
Legacy method to check credits for a known amount.
|
||||||
Used internally by deduct_credits.
|
Used internally by deduct_credits.
|
||||||
|
|
||||||
|
Uses total_credits (plan + bonus) for the check.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Account instance
|
account: Account instance
|
||||||
amount: Required credits amount
|
amount: Required credits amount
|
||||||
@@ -293,9 +299,10 @@ class CreditService:
|
|||||||
Raises:
|
Raises:
|
||||||
InsufficientCreditsError: If account doesn't have enough credits
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
"""
|
"""
|
||||||
if account.credits < amount:
|
total_available = account.credits + account.bonus_credits
|
||||||
|
if total_available < amount:
|
||||||
raise InsufficientCreditsError(
|
raise InsufficientCreditsError(
|
||||||
f"Insufficient credits. Required: {amount}, Available: {account.credits}"
|
f"Insufficient credits. Required: {amount}, Available: {total_available}"
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -358,6 +365,12 @@ class CreditService:
|
|||||||
"""
|
"""
|
||||||
Deduct credits and log transaction.
|
Deduct credits and log transaction.
|
||||||
|
|
||||||
|
CREDIT DEDUCTION ORDER:
|
||||||
|
1. Plan credits (account.credits) are consumed first
|
||||||
|
2. Bonus credits (account.bonus_credits) are consumed only when plan credits = 0
|
||||||
|
|
||||||
|
Bonus credits never expire and are not affected by plan renewal/reset.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Account instance
|
account: Account instance
|
||||||
amount: Number of credits to deduct
|
amount: Number of credits to deduct
|
||||||
@@ -373,26 +386,43 @@ class CreditService:
|
|||||||
site: Optional Site instance or site_id
|
site: Optional Site instance or site_id
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: New credit balance
|
int: New total credit balance (plan + bonus)
|
||||||
"""
|
"""
|
||||||
# Check sufficient credits (legacy: amount is already calculated)
|
# Check sufficient credits using total (plan + bonus)
|
||||||
CreditService.check_credits_legacy(account, amount)
|
total_available = account.credits + account.bonus_credits
|
||||||
|
if total_available < amount:
|
||||||
|
raise InsufficientCreditsError(
|
||||||
|
f"Insufficient credits. Required: {amount}, Available: {total_available}"
|
||||||
|
)
|
||||||
|
|
||||||
# Store previous balance for low credits check
|
# Store previous balance for low credits check
|
||||||
previous_balance = account.credits
|
previous_total = account.credits + account.bonus_credits
|
||||||
|
|
||||||
# Deduct from account.credits
|
# Calculate how much to deduct from each pool
|
||||||
account.credits -= amount
|
# Plan credits first, then bonus credits
|
||||||
account.save(update_fields=['credits'])
|
from_plan = min(account.credits, amount)
|
||||||
|
from_bonus = amount - from_plan
|
||||||
|
|
||||||
# Create CreditTransaction
|
# Deduct from plan credits first
|
||||||
|
account.credits -= from_plan
|
||||||
|
# Deduct remainder from bonus credits
|
||||||
|
if from_bonus > 0:
|
||||||
|
account.bonus_credits -= from_bonus
|
||||||
|
|
||||||
|
account.save(update_fields=['credits', 'bonus_credits'])
|
||||||
|
|
||||||
|
# Create CreditTransaction with details about source
|
||||||
CreditTransaction.objects.create(
|
CreditTransaction.objects.create(
|
||||||
account=account,
|
account=account,
|
||||||
transaction_type='deduction',
|
transaction_type='deduction',
|
||||||
amount=-amount, # Negative for deduction
|
amount=-amount, # Negative for deduction
|
||||||
balance_after=account.credits,
|
balance_after=account.credits + account.bonus_credits,
|
||||||
description=description,
|
description=description,
|
||||||
metadata=metadata or {}
|
metadata={
|
||||||
|
**(metadata or {}),
|
||||||
|
'from_plan_credits': from_plan,
|
||||||
|
'from_bonus_credits': from_bonus,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert site_id to Site instance if needed
|
# Convert site_id to Site instance if needed
|
||||||
@@ -419,13 +449,17 @@ class CreditService:
|
|||||||
tokens_output=tokens_output,
|
tokens_output=tokens_output,
|
||||||
related_object_type=related_object_type or '',
|
related_object_type=related_object_type or '',
|
||||||
related_object_id=related_object_id,
|
related_object_id=related_object_id,
|
||||||
metadata=metadata or {}
|
metadata={
|
||||||
|
**(metadata or {}),
|
||||||
|
'from_plan_credits': from_plan,
|
||||||
|
'from_bonus_credits': from_bonus,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check and send low credits warning if applicable
|
# Check and send low credits warning if applicable
|
||||||
_check_low_credits_warning(account, previous_balance)
|
_check_low_credits_warning(account, previous_total)
|
||||||
|
|
||||||
return account.credits
|
return account.credits + account.bonus_credits
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@@ -538,15 +572,62 @@ class CreditService:
|
|||||||
|
|
||||||
return account.credits
|
return account.credits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def add_bonus_credits(account, amount, description, metadata=None):
|
||||||
|
"""
|
||||||
|
Add bonus credits from credit package purchase.
|
||||||
|
|
||||||
|
Bonus credits:
|
||||||
|
- Never expire
|
||||||
|
- Are NOT reset on subscription renewal
|
||||||
|
- Are consumed ONLY after plan credits are exhausted
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account instance
|
||||||
|
amount: Number of credits to add
|
||||||
|
description: Description of the transaction
|
||||||
|
metadata: Optional metadata dict
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: New total credit balance (plan + bonus)
|
||||||
|
"""
|
||||||
|
# Add to bonus_credits (NOT plan credits)
|
||||||
|
account.bonus_credits += amount
|
||||||
|
account.save(update_fields=['bonus_credits'])
|
||||||
|
|
||||||
|
# Create CreditTransaction
|
||||||
|
CreditTransaction.objects.create(
|
||||||
|
account=account,
|
||||||
|
transaction_type='purchase', # Credit package purchase
|
||||||
|
amount=amount, # Positive for addition
|
||||||
|
balance_after=account.credits + account.bonus_credits, # Total balance
|
||||||
|
description=description,
|
||||||
|
metadata={
|
||||||
|
**(metadata or {}),
|
||||||
|
'credit_type': 'bonus',
|
||||||
|
'added_to_bonus_pool': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Bonus credits added: Account {account.id} - "
|
||||||
|
f"+{amount} bonus credits (new bonus balance: {account.bonus_credits})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return account.credits + account.bonus_credits
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def reset_credits_for_renewal(account, new_amount, description, metadata=None):
|
def reset_credits_for_renewal(account, new_amount, description, metadata=None):
|
||||||
"""
|
"""
|
||||||
Reset credits for subscription renewal (sets credits to new_amount instead of adding).
|
Reset PLAN credits for subscription renewal (sets credits to new_amount instead of adding).
|
||||||
|
|
||||||
This is used when a subscription renews - the credits are reset to the full
|
This is used when a subscription renews - the PLAN credits are reset to the full
|
||||||
plan amount, not added to existing balance.
|
plan amount, not added to existing balance.
|
||||||
|
|
||||||
|
IMPORTANT: Bonus credits are NOT affected by this reset.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Account instance
|
account: Account instance
|
||||||
new_amount: Number of credits to set (plan's included_credits)
|
new_amount: Number of credits to set (plan's included_credits)
|
||||||
@@ -554,36 +635,37 @@ class CreditService:
|
|||||||
metadata: Optional metadata dict
|
metadata: Optional metadata dict
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: New credit balance
|
int: New total credit balance (plan + bonus)
|
||||||
"""
|
"""
|
||||||
old_balance = account.credits
|
old_plan_balance = account.credits
|
||||||
account.credits = new_amount
|
account.credits = new_amount
|
||||||
account.save(update_fields=['credits'])
|
account.save(update_fields=['credits']) # Only update plan credits
|
||||||
|
|
||||||
# Calculate the change for the transaction record
|
# Calculate the change for the transaction record
|
||||||
change_amount = new_amount - old_balance
|
change_amount = new_amount - old_plan_balance
|
||||||
|
|
||||||
# Create CreditTransaction - use 'subscription' type for renewal
|
# Create CreditTransaction - use 'subscription' type for renewal
|
||||||
CreditTransaction.objects.create(
|
CreditTransaction.objects.create(
|
||||||
account=account,
|
account=account,
|
||||||
transaction_type='subscription', # Uses 'Subscription Renewal' display
|
transaction_type='subscription', # Uses 'Subscription Renewal' display
|
||||||
amount=change_amount, # Can be positive or negative depending on usage
|
amount=change_amount, # Can be positive or negative depending on usage
|
||||||
balance_after=account.credits,
|
balance_after=account.credits + account.bonus_credits, # Total balance
|
||||||
description=description,
|
description=description,
|
||||||
metadata={
|
metadata={
|
||||||
**(metadata or {}),
|
**(metadata or {}),
|
||||||
'reset_from': old_balance,
|
'reset_from': old_plan_balance,
|
||||||
'reset_to': new_amount,
|
'reset_to': new_amount,
|
||||||
'is_renewal_reset': True
|
'is_renewal_reset': True,
|
||||||
|
'bonus_credits_unchanged': account.bonus_credits,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Credits reset for renewal: Account {account.id} - "
|
f"Plan credits reset for renewal: Account {account.id} - "
|
||||||
f"from {old_balance} to {new_amount} (change: {change_amount})"
|
f"Plan: {old_plan_balance} → {new_amount}, Bonus: {account.bonus_credits} (unchanged)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return account.credits
|
return account.credits + account.bonus_credits
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
|||||||
@@ -1127,6 +1127,147 @@ To view and pay your invoice:
|
|||||||
|
|
||||||
If you have any questions, please contact our support team.
|
If you have any questions, please contact our support team.
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
The IGNY8 Team
|
||||||
|
""".strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_credit_invoice_expiring_email(invoice):
|
||||||
|
"""Notify user that a credit invoice will expire soon."""
|
||||||
|
service = get_email_service()
|
||||||
|
frontend_url = BillingEmailService._get_frontend_url()
|
||||||
|
|
||||||
|
account = invoice.account
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'account_name': account.name,
|
||||||
|
'invoice_number': invoice.invoice_number or f'INV-{invoice.id}',
|
||||||
|
'total_amount': invoice.total_amount,
|
||||||
|
'currency': invoice.currency or 'USD',
|
||||||
|
'expires_at': invoice.expires_at.strftime('%Y-%m-%d %H:%M') if invoice.expires_at else 'N/A',
|
||||||
|
'invoice_url': f'{frontend_url}/billing/invoices/{invoice.id}',
|
||||||
|
'frontend_url': frontend_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"Invoice #{context['invoice_number']} expiring soon"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = service.send_transactional(
|
||||||
|
to=account.billing_email or account.owner.email,
|
||||||
|
subject=subject,
|
||||||
|
template='emails/credit_invoice_expiring.html',
|
||||||
|
context=context,
|
||||||
|
tags=['billing', 'invoice', 'expiring'],
|
||||||
|
)
|
||||||
|
logger.info(f'Credit invoice expiring email sent for Invoice {invoice.id}')
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send credit invoice expiring email: {str(e)}')
|
||||||
|
return service.send_transactional(
|
||||||
|
to=account.billing_email or account.owner.email,
|
||||||
|
subject=subject,
|
||||||
|
text=f"""
|
||||||
|
Hi {account.name},
|
||||||
|
|
||||||
|
Your credit invoice #{context['invoice_number']} is expiring soon.
|
||||||
|
|
||||||
|
Amount: {context['currency']} {context['total_amount']}
|
||||||
|
Expires at: {context['expires_at']}
|
||||||
|
|
||||||
|
Complete payment here:
|
||||||
|
{context['invoice_url']}
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
The IGNY8 Team
|
||||||
|
""".strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_credit_invoice_expired_email(invoice):
|
||||||
|
"""Notify user that a credit invoice has expired."""
|
||||||
|
service = get_email_service()
|
||||||
|
frontend_url = BillingEmailService._get_frontend_url()
|
||||||
|
|
||||||
|
account = invoice.account
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'account_name': account.name,
|
||||||
|
'invoice_number': invoice.invoice_number or f'INV-{invoice.id}',
|
||||||
|
'invoice_url': f'{frontend_url}/billing/invoices/{invoice.id}',
|
||||||
|
'frontend_url': frontend_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"Invoice #{context['invoice_number']} expired"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = service.send_transactional(
|
||||||
|
to=account.billing_email or account.owner.email,
|
||||||
|
subject=subject,
|
||||||
|
template='emails/credit_invoice_expired.html',
|
||||||
|
context=context,
|
||||||
|
tags=['billing', 'invoice', 'expired'],
|
||||||
|
)
|
||||||
|
logger.info(f'Credit invoice expired email sent for Invoice {invoice.id}')
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send credit invoice expired email: {str(e)}')
|
||||||
|
return service.send_transactional(
|
||||||
|
to=account.billing_email or account.owner.email,
|
||||||
|
subject=subject,
|
||||||
|
text=f"""
|
||||||
|
Hi {account.name},
|
||||||
|
|
||||||
|
Your credit invoice #{context['invoice_number']} has expired and was voided.
|
||||||
|
|
||||||
|
You can create a new credit purchase anytime from your billing page:
|
||||||
|
{context['invoice_url']}
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
The IGNY8 Team
|
||||||
|
""".strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_credit_invoice_cancelled_email(invoice):
|
||||||
|
"""Notify user that a credit invoice was cancelled."""
|
||||||
|
service = get_email_service()
|
||||||
|
frontend_url = BillingEmailService._get_frontend_url()
|
||||||
|
|
||||||
|
account = invoice.account
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'account_name': account.name,
|
||||||
|
'invoice_number': invoice.invoice_number or f'INV-{invoice.id}',
|
||||||
|
'invoice_url': f'{frontend_url}/billing/invoices/{invoice.id}',
|
||||||
|
'frontend_url': frontend_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"Invoice #{context['invoice_number']} cancelled"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = service.send_transactional(
|
||||||
|
to=account.billing_email or account.owner.email,
|
||||||
|
subject=subject,
|
||||||
|
template='emails/credit_invoice_cancelled.html',
|
||||||
|
context=context,
|
||||||
|
tags=['billing', 'invoice', 'cancelled'],
|
||||||
|
)
|
||||||
|
logger.info(f'Credit invoice cancelled email sent for Invoice {invoice.id}')
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send credit invoice cancelled email: {str(e)}')
|
||||||
|
return service.send_transactional(
|
||||||
|
to=account.billing_email or account.owner.email,
|
||||||
|
subject=subject,
|
||||||
|
text=f"""
|
||||||
|
Hi {account.name},
|
||||||
|
|
||||||
|
Your credit invoice #{context['invoice_number']} was cancelled.
|
||||||
|
|
||||||
|
You can create a new credit purchase anytime from your billing page:
|
||||||
|
{context['invoice_url']}
|
||||||
|
|
||||||
Thank you,
|
Thank you,
|
||||||
The IGNY8 Team
|
The IGNY8 Team
|
||||||
""".strip(),
|
""".strip(),
|
||||||
@@ -1222,6 +1363,60 @@ Please update your payment method to continue your subscription:
|
|||||||
|
|
||||||
If you need assistance, please contact our support team.
|
If you need assistance, please contact our support team.
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
The IGNY8 Team
|
||||||
|
""".strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_credits_reset_warning(account, subscription, previous_credits=0):
|
||||||
|
"""
|
||||||
|
Send warning email when credits are reset to 0 due to non-payment after 24 hours.
|
||||||
|
"""
|
||||||
|
service = get_email_service()
|
||||||
|
frontend_url = BillingEmailService._get_frontend_url()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'account_name': account.name,
|
||||||
|
'plan_name': subscription.plan.name if subscription and subscription.plan else 'N/A',
|
||||||
|
'previous_credits': previous_credits,
|
||||||
|
'frontend_url': frontend_url,
|
||||||
|
'billing_url': f'{frontend_url}/account/plans',
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = service.send_transactional(
|
||||||
|
to=account.billing_email or account.owner.email,
|
||||||
|
subject='Credits Reset - Payment Required to Restore',
|
||||||
|
template='emails/credits_reset_warning.html',
|
||||||
|
context=context,
|
||||||
|
tags=['billing', 'credits-reset'],
|
||||||
|
)
|
||||||
|
logger.info(f'Credits reset warning email sent for account {account.id}')
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send credits reset warning email: {str(e)}')
|
||||||
|
return service.send_transactional(
|
||||||
|
to=account.billing_email or account.owner.email,
|
||||||
|
subject='Credits Reset - Payment Required to Restore',
|
||||||
|
text=f"""
|
||||||
|
Hi {account.name},
|
||||||
|
|
||||||
|
Your plan credits have been reset to 0 because your subscription renewal payment was not received within 24 hours of your renewal date.
|
||||||
|
|
||||||
|
Previous credits: {previous_credits}
|
||||||
|
Current credits: 0
|
||||||
|
Plan: {context['plan_name']}
|
||||||
|
|
||||||
|
Your bonus credits (if any) remain unaffected.
|
||||||
|
|
||||||
|
To restore your credits and continue using IGNY8:
|
||||||
|
{context['billing_url']}
|
||||||
|
|
||||||
|
Please complete your payment as soon as possible to restore your credits and continue enjoying uninterrupted service.
|
||||||
|
|
||||||
|
If you have any questions or need assistance, please contact our support team.
|
||||||
|
|
||||||
Thank you,
|
Thank you,
|
||||||
The IGNY8 Team
|
The IGNY8 Team
|
||||||
""".strip(),
|
""".strip(),
|
||||||
|
|||||||
@@ -14,6 +14,30 @@ from ....auth.models import Account, Subscription
|
|||||||
class InvoiceService:
|
class InvoiceService:
|
||||||
"""Service for managing invoices"""
|
"""Service for managing invoices"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_invoice_type(invoice: Invoice) -> str:
|
||||||
|
"""Determine invoice type using explicit field with legacy fallbacks."""
|
||||||
|
if getattr(invoice, 'invoice_type', None):
|
||||||
|
return invoice.invoice_type
|
||||||
|
if invoice.metadata and invoice.metadata.get('credit_package_id'):
|
||||||
|
return 'credit_package'
|
||||||
|
if getattr(invoice, 'subscription_id', None):
|
||||||
|
return 'subscription'
|
||||||
|
return 'custom'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_credit_package_from_invoice(invoice: Invoice) -> Optional[CreditPackage]:
|
||||||
|
"""Resolve credit package from invoice metadata when present."""
|
||||||
|
credit_package_id = None
|
||||||
|
if invoice.metadata:
|
||||||
|
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
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_pending_invoice(subscription: Subscription) -> Optional[Invoice]:
|
def get_pending_invoice(subscription: Subscription) -> Optional[Invoice]:
|
||||||
"""
|
"""
|
||||||
@@ -126,6 +150,7 @@ class InvoiceService:
|
|||||||
account=account,
|
account=account,
|
||||||
subscription=subscription, # Set FK directly
|
subscription=subscription, # Set FK directly
|
||||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||||
|
invoice_type='subscription',
|
||||||
status='pending',
|
status='pending',
|
||||||
currency=currency,
|
currency=currency,
|
||||||
invoice_date=invoice_date,
|
invoice_date=invoice_date,
|
||||||
@@ -174,6 +199,7 @@ class InvoiceService:
|
|||||||
|
|
||||||
# ALWAYS use USD for invoices (simplified accounting)
|
# ALWAYS use USD for invoices (simplified accounting)
|
||||||
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
||||||
|
from igny8_core.business.billing.config import CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS
|
||||||
|
|
||||||
currency = 'USD'
|
currency = 'USD'
|
||||||
usd_price = float(credit_package.price)
|
usd_price = float(credit_package.price)
|
||||||
@@ -192,10 +218,12 @@ class InvoiceService:
|
|||||||
invoice = Invoice.objects.create(
|
invoice = Invoice.objects.create(
|
||||||
account=account,
|
account=account,
|
||||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||||
|
invoice_type='credit_package',
|
||||||
status='pending',
|
status='pending',
|
||||||
currency=currency,
|
currency=currency,
|
||||||
invoice_date=invoice_date,
|
invoice_date=invoice_date,
|
||||||
due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET),
|
due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET),
|
||||||
|
expires_at=timezone.now() + timedelta(hours=CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS),
|
||||||
metadata={
|
metadata={
|
||||||
'billing_snapshot': {
|
'billing_snapshot': {
|
||||||
'email': billing_email,
|
'email': billing_email,
|
||||||
@@ -255,6 +283,7 @@ class InvoiceService:
|
|||||||
invoice = Invoice.objects.create(
|
invoice = Invoice.objects.create(
|
||||||
account=account,
|
account=account,
|
||||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||||
|
invoice_type='custom',
|
||||||
status='draft',
|
status='draft',
|
||||||
currency='USD',
|
currency='USD',
|
||||||
notes=notes,
|
notes=notes,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Payment Service - Handles payment processing across multiple gateways
|
Payment Service - Handles payment processing across multiple gateways
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@@ -9,6 +10,9 @@ from django.utils import timezone
|
|||||||
from ..models import Payment, Invoice, CreditPackage, PaymentMethodConfig, CreditTransaction
|
from ..models import Payment, Invoice, CreditPackage, PaymentMethodConfig, CreditTransaction
|
||||||
from ....auth.models import Account
|
from ....auth.models import Account
|
||||||
|
|
||||||
|
# Use dedicated payment logger (logs to /logs/billing-logs/payments.log)
|
||||||
|
logger = logging.getLogger('payments')
|
||||||
|
|
||||||
|
|
||||||
class PaymentService:
|
class PaymentService:
|
||||||
"""Service for processing payments across multiple gateways"""
|
"""Service for processing payments across multiple gateways"""
|
||||||
@@ -24,6 +28,7 @@ class PaymentService:
|
|||||||
"""
|
"""
|
||||||
Create payment record for Stripe transaction
|
Create payment record for Stripe transaction
|
||||||
"""
|
"""
|
||||||
|
logger.info(f"Creating Stripe payment for invoice {invoice.invoice_number}, PI: {stripe_payment_intent_id}")
|
||||||
payment = Payment.objects.create(
|
payment = Payment.objects.create(
|
||||||
account=invoice.account,
|
account=invoice.account,
|
||||||
invoice=invoice,
|
invoice=invoice,
|
||||||
@@ -35,7 +40,7 @@ class PaymentService:
|
|||||||
stripe_charge_id=stripe_charge_id,
|
stripe_charge_id=stripe_charge_id,
|
||||||
metadata=metadata or {}
|
metadata=metadata or {}
|
||||||
)
|
)
|
||||||
|
logger.info(f"Created Stripe payment {payment.id} for account {invoice.account_id}")
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -48,6 +53,7 @@ class PaymentService:
|
|||||||
"""
|
"""
|
||||||
Create payment record for PayPal transaction
|
Create payment record for PayPal transaction
|
||||||
"""
|
"""
|
||||||
|
logger.info(f"Creating PayPal payment for invoice {invoice.invoice_number}, Order: {paypal_order_id}")
|
||||||
payment = Payment.objects.create(
|
payment = Payment.objects.create(
|
||||||
account=invoice.account,
|
account=invoice.account,
|
||||||
invoice=invoice,
|
invoice=invoice,
|
||||||
@@ -182,13 +188,20 @@ class PaymentService:
|
|||||||
transaction_id=payment.transaction_reference
|
transaction_id=payment.transaction_reference
|
||||||
)
|
)
|
||||||
|
|
||||||
# If payment is for credit package, add credits
|
# Apply fulfillment based on invoice type
|
||||||
if payment.metadata.get('credit_package_id'):
|
|
||||||
PaymentService._add_credits_for_payment(payment)
|
|
||||||
|
|
||||||
# If account is inactive/suspended/trial, activate it on successful payment
|
|
||||||
try:
|
try:
|
||||||
account = payment.account
|
account = payment.account
|
||||||
|
invoice = payment.invoice
|
||||||
|
from .invoice_service import InvoiceService
|
||||||
|
|
||||||
|
invoice_type = InvoiceService.get_invoice_type(invoice) if invoice else 'custom'
|
||||||
|
|
||||||
|
if invoice_type == 'credit_package':
|
||||||
|
PaymentService._add_credits_for_payment(payment)
|
||||||
|
elif invoice_type == 'subscription':
|
||||||
|
if invoice and getattr(invoice, 'subscription', None):
|
||||||
|
invoice.subscription.status = 'active'
|
||||||
|
invoice.subscription.save(update_fields=['status', 'updated_at'])
|
||||||
if account and account.status != 'active':
|
if account and account.status != 'active':
|
||||||
account.status = 'active'
|
account.status = 'active'
|
||||||
account.save(update_fields=['status', 'updated_at'])
|
account.save(update_fields=['status', 'updated_at'])
|
||||||
@@ -222,9 +235,16 @@ class PaymentService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _add_credits_for_payment(payment: Payment) -> None:
|
def _add_credits_for_payment(payment: Payment) -> None:
|
||||||
"""
|
"""
|
||||||
Add credits to account after successful payment
|
Add bonus credits to account after successful credit package payment.
|
||||||
|
|
||||||
|
Bonus credits:
|
||||||
|
- Never expire
|
||||||
|
- Are NOT reset on subscription renewal
|
||||||
|
- Are consumed ONLY after plan credits are exhausted
|
||||||
"""
|
"""
|
||||||
credit_package_id = payment.metadata.get('credit_package_id')
|
credit_package_id = payment.metadata.get('credit_package_id')
|
||||||
|
if not credit_package_id and payment.invoice and payment.invoice.metadata:
|
||||||
|
credit_package_id = payment.invoice.metadata.get('credit_package_id')
|
||||||
if not credit_package_id:
|
if not credit_package_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -233,18 +253,13 @@ class PaymentService:
|
|||||||
except CreditPackage.DoesNotExist:
|
except CreditPackage.DoesNotExist:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update account balance
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
account: Account = payment.account
|
|
||||||
account.credits = (account.credits or 0) + credit_package.credits
|
|
||||||
account.save(update_fields=['credits', 'updated_at'])
|
|
||||||
|
|
||||||
# Create credit transaction
|
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
|
||||||
CreditTransaction.objects.create(
|
CreditService.add_bonus_credits(
|
||||||
account=payment.account,
|
account=payment.account,
|
||||||
amount=credit_package.credits,
|
amount=credit_package.credits,
|
||||||
transaction_type='purchase',
|
description=f"Purchased {credit_package.name} ({credit_package.credits} bonus credits)",
|
||||||
description=f"Purchased {credit_package.name}",
|
|
||||||
reference_id=str(payment.id),
|
|
||||||
metadata={
|
metadata={
|
||||||
'payment_id': payment.id,
|
'payment_id': payment.id,
|
||||||
'credit_package_id': credit_package_id,
|
'credit_package_id': credit_package_id,
|
||||||
|
|||||||
12
backend/igny8_core/business/billing/tasks/__init__.py
Normal file
12
backend/igny8_core/business/billing/tasks/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Import tasks so they can be discovered by Celery autodiscover
|
||||||
|
from .invoice_lifecycle import (
|
||||||
|
send_credit_invoice_expiry_reminders,
|
||||||
|
void_expired_credit_invoices,
|
||||||
|
)
|
||||||
|
from .subscription_renewal import (
|
||||||
|
send_renewal_notices,
|
||||||
|
process_subscription_renewals,
|
||||||
|
renew_subscription,
|
||||||
|
send_invoice_reminders,
|
||||||
|
check_expired_renewals,
|
||||||
|
)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Invoice lifecycle tasks (expiry/reminders)
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
from celery import shared_task
|
||||||
|
from django.utils import timezone
|
||||||
|
from igny8_core.business.billing.models import Invoice
|
||||||
|
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EXPIRY_REMINDER_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='billing.send_credit_invoice_expiry_reminders')
|
||||||
|
def send_credit_invoice_expiry_reminders():
|
||||||
|
"""Send reminder emails for credit package invoices expiring soon."""
|
||||||
|
now = timezone.now()
|
||||||
|
threshold = now + timedelta(hours=EXPIRY_REMINDER_HOURS)
|
||||||
|
|
||||||
|
invoices = Invoice.objects.filter(
|
||||||
|
status='pending',
|
||||||
|
invoice_type='credit_package',
|
||||||
|
expires_at__isnull=False,
|
||||||
|
expires_at__lte=threshold,
|
||||||
|
expires_at__gt=now,
|
||||||
|
).select_related('account')
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
for invoice in invoices:
|
||||||
|
metadata = invoice.metadata or {}
|
||||||
|
if metadata.get('expiry_reminder_sent'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
BillingEmailService.send_credit_invoice_expiring_email(invoice)
|
||||||
|
metadata['expiry_reminder_sent'] = True
|
||||||
|
metadata['expiry_reminder_sent_at'] = now.isoformat()
|
||||||
|
invoice.metadata = metadata
|
||||||
|
invoice.save(update_fields=['metadata'])
|
||||||
|
sent += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to send expiry reminder for invoice {invoice.id}: {str(e)}")
|
||||||
|
|
||||||
|
if sent:
|
||||||
|
logger.info(f"Sent {sent} credit invoice expiry reminders")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='billing.void_expired_credit_invoices')
|
||||||
|
def void_expired_credit_invoices():
|
||||||
|
"""Void expired credit package invoices and notify users."""
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
invoices = Invoice.objects.filter(
|
||||||
|
status='pending',
|
||||||
|
invoice_type='credit_package',
|
||||||
|
expires_at__isnull=False,
|
||||||
|
expires_at__lte=now,
|
||||||
|
).select_related('account')
|
||||||
|
|
||||||
|
voided = 0
|
||||||
|
for invoice in invoices:
|
||||||
|
try:
|
||||||
|
invoice.status = 'void'
|
||||||
|
invoice.void_reason = 'expired'
|
||||||
|
invoice.save(update_fields=['status', 'void_reason', 'updated_at'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
BillingEmailService.send_credit_invoice_expired_email(invoice)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
voided += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to void invoice {invoice.id}: {str(e)}")
|
||||||
|
|
||||||
|
if voided:
|
||||||
|
logger.info(f"Voided {voided} expired credit invoices")
|
||||||
@@ -1,66 +1,135 @@
|
|||||||
"""
|
"""
|
||||||
Subscription renewal tasks
|
Subscription renewal tasks
|
||||||
Handles automatic subscription renewals with Celery
|
Handles automatic subscription renewals with Celery
|
||||||
|
|
||||||
|
Workflow by Payment Method:
|
||||||
|
1. Stripe/PayPal (auto-pay): No advance notice (industry standard)
|
||||||
|
- Invoice created on renewal day, auto-charged immediately
|
||||||
|
- If payment fails: retry email sent, user can Pay Now
|
||||||
|
- Credits reset on payment success
|
||||||
|
|
||||||
|
2. Bank Transfer (manual):
|
||||||
|
- Day -3: Invoice created + email sent
|
||||||
|
- Day 0: Renewal day reminder (if unpaid)
|
||||||
|
- Day +1: Urgent reminder + credits reset to 0 (if unpaid)
|
||||||
|
- Day +7: Subscription expired
|
||||||
"""
|
"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from igny8_core.business.billing.models import Subscription, Invoice, Payment
|
from igny8_core.auth.models import Subscription
|
||||||
|
from igny8_core.business.billing.models import Invoice, Payment
|
||||||
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||||
from igny8_core.business.billing.config import SUBSCRIPTION_RENEWAL_NOTICE_DAYS
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Grace period in days for manual payment before subscription expires
|
# Grace period in days for manual payment before subscription expires
|
||||||
RENEWAL_GRACE_PERIOD_DAYS = 7
|
RENEWAL_GRACE_PERIOD_DAYS = 7
|
||||||
# Days between invoice reminder emails
|
# Days before renewal to generate invoice for bank transfer (manual payment)
|
||||||
INVOICE_REMINDER_INTERVAL_DAYS = 3
|
BANK_TRANSFER_ADVANCE_INVOICE_DAYS = 3
|
||||||
|
# Hours after renewal to reset credits if payment not received (for bank transfer)
|
||||||
|
CREDIT_RESET_DELAY_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='billing.send_renewal_notices')
|
@shared_task(name='billing.create_bank_transfer_invoices')
|
||||||
def send_renewal_notices():
|
def create_bank_transfer_invoices():
|
||||||
"""
|
"""
|
||||||
Send renewal notice emails to subscribers
|
Create invoices for bank transfer users 3 days before renewal.
|
||||||
Run daily to check subscriptions expiring soon
|
Run daily at 09:00.
|
||||||
"""
|
|
||||||
notice_date = timezone.now().date() + timedelta(days=SUBSCRIPTION_RENEWAL_NOTICE_DAYS)
|
Only for manual payment accounts (bank transfer, local wallet).
|
||||||
|
Stripe/PayPal users get auto-charged on renewal day - no advance invoice.
|
||||||
|
"""
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
# Generate invoices for bank transfer users (3 days before renewal)
|
||||||
|
invoice_date = now.date() + timedelta(days=BANK_TRANSFER_ADVANCE_INVOICE_DAYS)
|
||||||
|
|
||||||
# Get active subscriptions expiring on notice_date
|
|
||||||
subscriptions = Subscription.objects.filter(
|
subscriptions = Subscription.objects.filter(
|
||||||
status='active',
|
status='active',
|
||||||
current_period_end__date=notice_date
|
current_period_end__date=invoice_date
|
||||||
).select_related('account', 'plan', 'account__owner')
|
).select_related('account', 'plan')
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
for subscription in subscriptions:
|
for subscription in subscriptions:
|
||||||
# Check if notice already sent
|
# Only for bank transfer / manual payment users
|
||||||
if subscription.metadata.get('renewal_notice_sent'):
|
if not _is_manual_payment_account(subscription):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if advance invoice already created
|
||||||
|
if subscription.metadata.get('advance_invoice_created'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
BillingEmailService.send_subscription_renewal_notice(
|
# Create advance invoice
|
||||||
subscription=subscription,
|
invoice = InvoiceService.create_subscription_invoice(
|
||||||
days_until_renewal=SUBSCRIPTION_RENEWAL_NOTICE_DAYS
|
account=subscription.account,
|
||||||
|
subscription=subscription
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark notice as sent
|
# Mark advance invoice created
|
||||||
subscription.metadata['renewal_notice_sent'] = True
|
subscription.metadata['advance_invoice_created'] = True
|
||||||
subscription.metadata['renewal_notice_sent_at'] = timezone.now().isoformat()
|
subscription.metadata['advance_invoice_id'] = invoice.id
|
||||||
|
subscription.metadata['advance_invoice_created_at'] = now.isoformat()
|
||||||
subscription.save(update_fields=['metadata'])
|
subscription.save(update_fields=['metadata'])
|
||||||
|
|
||||||
logger.info(f"Renewal notice sent for subscription {subscription.id}")
|
# Send invoice email
|
||||||
|
try:
|
||||||
|
BillingEmailService.send_invoice_email(
|
||||||
|
invoice,
|
||||||
|
is_reminder=False,
|
||||||
|
extra_context={
|
||||||
|
'days_until_renewal': BANK_TRANSFER_ADVANCE_INVOICE_DAYS,
|
||||||
|
'is_advance_invoice': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to send advance invoice email: {str(e)}")
|
||||||
|
|
||||||
|
created_count += 1
|
||||||
|
logger.info(f"Advance invoice created for bank transfer subscription {subscription.id}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Failed to send renewal notice for subscription {subscription.id}: {str(e)}")
|
logger.exception(f"Failed to create advance invoice for subscription {subscription.id}: {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"Created {created_count} advance invoices for bank transfer renewals")
|
||||||
|
return created_count
|
||||||
|
|
||||||
|
|
||||||
|
def _is_manual_payment_account(subscription: Subscription) -> bool:
|
||||||
|
"""
|
||||||
|
Check if account uses manual payment (bank transfer, local wallet)
|
||||||
|
These accounts don't have automatic billing set up
|
||||||
|
"""
|
||||||
|
# Has no automatic payment method configured
|
||||||
|
if subscription.metadata.get('stripe_subscription_id'):
|
||||||
|
return False
|
||||||
|
if subscription.metadata.get('paypal_subscription_id'):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check account's billing country (PK = bank transfer)
|
||||||
|
account = subscription.account
|
||||||
|
if hasattr(account, 'billing_country') and account.billing_country == 'PK':
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check payment method preference in metadata
|
||||||
|
if subscription.metadata.get('payment_method') in ['bank_transfer', 'local_wallet', 'manual']:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return True # Default to manual if no auto-payment configured
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='billing.process_subscription_renewals')
|
@shared_task(name='billing.process_subscription_renewals')
|
||||||
def process_subscription_renewals():
|
def process_subscription_renewals():
|
||||||
"""
|
"""
|
||||||
Process subscription renewals for subscriptions ending today
|
Process subscription renewals for subscriptions ending today.
|
||||||
Run daily at midnight to renew subscriptions
|
Run daily at 00:05.
|
||||||
|
|
||||||
|
For Stripe/PayPal: Create invoice, attempt auto-charge, restore credits on success
|
||||||
|
For Bank Transfer: Invoice already created 3 days ago, just send renewal day reminder
|
||||||
"""
|
"""
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
|
|
||||||
@@ -82,7 +151,12 @@ def process_subscription_renewals():
|
|||||||
@shared_task(name='billing.renew_subscription')
|
@shared_task(name='billing.renew_subscription')
|
||||||
def renew_subscription(subscription_id: int):
|
def renew_subscription(subscription_id: int):
|
||||||
"""
|
"""
|
||||||
Renew a specific subscription
|
Renew a specific subscription.
|
||||||
|
|
||||||
|
Key behavior changes:
|
||||||
|
- Credits are NOT reset at cycle end
|
||||||
|
- Credits only reset after payment confirmed (for successful renewals)
|
||||||
|
- Credits reset to 0 after 24 hours if payment not received
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
subscription_id: Subscription ID to renew
|
subscription_id: Subscription ID to renew
|
||||||
@@ -95,44 +169,91 @@ def renew_subscription(subscription_id: int):
|
|||||||
return
|
return
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Create renewal invoice
|
# Check if this is a manual payment account with advance invoice
|
||||||
|
is_manual = _is_manual_payment_account(subscription)
|
||||||
|
|
||||||
|
if is_manual:
|
||||||
|
# For bank transfer: Invoice was created 3 days ago
|
||||||
|
# Just mark as pending renewal and send reminder
|
||||||
|
invoice_id = subscription.metadata.get('advance_invoice_id')
|
||||||
|
if invoice_id:
|
||||||
|
try:
|
||||||
|
invoice = Invoice.objects.get(id=invoice_id)
|
||||||
|
# Invoice already exists, check if paid
|
||||||
|
if invoice.status == 'paid':
|
||||||
|
# Payment received before renewal date - great!
|
||||||
|
_complete_renewal(subscription, invoice)
|
||||||
|
logger.info(f"Bank transfer subscription {subscription_id} renewed (advance payment received)")
|
||||||
|
return
|
||||||
|
except Invoice.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Not paid yet - mark as pending renewal
|
||||||
|
grace_period_end = timezone.now() + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)
|
||||||
|
credit_reset_time = timezone.now() + timedelta(hours=CREDIT_RESET_DELAY_HOURS)
|
||||||
|
|
||||||
|
subscription.status = 'pending_renewal'
|
||||||
|
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
|
||||||
|
subscription.metadata['grace_period_end'] = grace_period_end.isoformat()
|
||||||
|
subscription.metadata['credit_reset_scheduled_at'] = credit_reset_time.isoformat()
|
||||||
|
subscription.metadata['last_invoice_reminder_at'] = timezone.now().isoformat()
|
||||||
|
subscription.save(update_fields=['status', 'metadata'])
|
||||||
|
|
||||||
|
# Send renewal day reminder
|
||||||
|
if invoice_id:
|
||||||
|
try:
|
||||||
|
invoice = Invoice.objects.get(id=invoice_id)
|
||||||
|
BillingEmailService.send_invoice_email(
|
||||||
|
invoice,
|
||||||
|
is_reminder=True,
|
||||||
|
extra_context={'is_renewal_day': True}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to send renewal day reminder: {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"Bank transfer subscription {subscription_id} marked pending renewal")
|
||||||
|
else:
|
||||||
|
# For Stripe/PayPal: Create invoice and attempt auto-charge
|
||||||
invoice = InvoiceService.create_subscription_invoice(
|
invoice = InvoiceService.create_subscription_invoice(
|
||||||
account=subscription.account,
|
account=subscription.account,
|
||||||
subscription=subscription
|
subscription=subscription
|
||||||
)
|
)
|
||||||
|
|
||||||
# Attempt automatic payment if payment method on file
|
|
||||||
payment_attempted = False
|
payment_attempted = False
|
||||||
|
|
||||||
# Check if account has saved payment method for automatic billing
|
|
||||||
if subscription.metadata.get('stripe_subscription_id'):
|
if subscription.metadata.get('stripe_subscription_id'):
|
||||||
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
|
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
|
||||||
elif subscription.metadata.get('paypal_subscription_id'):
|
elif subscription.metadata.get('paypal_subscription_id'):
|
||||||
payment_attempted = _attempt_paypal_renewal(subscription, invoice)
|
payment_attempted = _attempt_paypal_renewal(subscription, invoice)
|
||||||
|
|
||||||
if payment_attempted:
|
if payment_attempted:
|
||||||
# Payment processing will handle subscription renewal
|
# Payment processing will handle credit reset via webhook
|
||||||
|
subscription.metadata['renewal_invoice_id'] = invoice.id
|
||||||
|
subscription.save(update_fields=['metadata'])
|
||||||
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
|
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
|
||||||
else:
|
else:
|
||||||
# No automatic payment - send invoice for manual payment
|
# Auto-payment failed - mark as pending with Pay Now option
|
||||||
# This handles all payment methods: bank_transfer, local_wallet, manual
|
|
||||||
logger.info(f"Manual payment required for subscription {subscription_id}")
|
|
||||||
|
|
||||||
# Mark subscription as pending renewal with grace period
|
|
||||||
grace_period_end = timezone.now() + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)
|
grace_period_end = timezone.now() + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)
|
||||||
|
credit_reset_time = timezone.now() + timedelta(hours=CREDIT_RESET_DELAY_HOURS)
|
||||||
|
|
||||||
subscription.status = 'pending_renewal'
|
subscription.status = 'pending_renewal'
|
||||||
subscription.metadata['renewal_invoice_id'] = invoice.id
|
subscription.metadata['renewal_invoice_id'] = invoice.id
|
||||||
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
|
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
|
||||||
subscription.metadata['grace_period_end'] = grace_period_end.isoformat()
|
subscription.metadata['grace_period_end'] = grace_period_end.isoformat()
|
||||||
subscription.metadata['last_invoice_reminder_at'] = timezone.now().isoformat()
|
subscription.metadata['credit_reset_scheduled_at'] = credit_reset_time.isoformat()
|
||||||
|
subscription.metadata['auto_payment_failed'] = True
|
||||||
subscription.save(update_fields=['status', 'metadata'])
|
subscription.save(update_fields=['status', 'metadata'])
|
||||||
|
|
||||||
# Send invoice email for manual payment
|
|
||||||
try:
|
try:
|
||||||
BillingEmailService.send_invoice_email(invoice, is_reminder=False)
|
BillingEmailService.send_invoice_email(
|
||||||
logger.info(f"Invoice email sent for subscription {subscription_id}")
|
invoice,
|
||||||
|
is_reminder=False,
|
||||||
|
extra_context={'auto_payment_failed': True, 'show_pay_now': True}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Failed to send invoice email for subscription {subscription_id}: {str(e)}")
|
logger.exception(f"Failed to send invoice email: {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"Auto-payment failed for subscription {subscription_id}, manual payment required")
|
||||||
|
|
||||||
# Clear renewal notice flag
|
# Clear renewal notice flag
|
||||||
if 'renewal_notice_sent' in subscription.metadata:
|
if 'renewal_notice_sent' in subscription.metadata:
|
||||||
@@ -145,29 +266,114 @@ def renew_subscription(subscription_id: int):
|
|||||||
logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}")
|
logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='billing.send_invoice_reminders')
|
def _complete_renewal(subscription: Subscription, invoice: Invoice):
|
||||||
def send_invoice_reminders():
|
|
||||||
"""
|
"""
|
||||||
Send invoice reminder emails for pending renewals
|
Complete a successful renewal - reset plan credits to full amount.
|
||||||
Run daily to remind accounts with pending invoices
|
Called when payment is confirmed (either via webhook or manual approval).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
|
||||||
|
if subscription.plan and (subscription.plan.included_credits or 0) > 0:
|
||||||
|
CreditService.reset_credits_for_renewal(
|
||||||
|
account=subscription.account,
|
||||||
|
new_amount=subscription.plan.included_credits,
|
||||||
|
description=f'Subscription renewed - {subscription.plan.name}',
|
||||||
|
metadata={
|
||||||
|
'subscription_id': subscription.id,
|
||||||
|
'plan_id': subscription.plan.id,
|
||||||
|
'invoice_id': invoice.id if invoice else None,
|
||||||
|
'reset_reason': 'renewal_payment_confirmed'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update subscription status and period
|
||||||
|
subscription.status = 'active'
|
||||||
|
subscription.current_period_start = subscription.current_period_end
|
||||||
|
subscription.current_period_end = subscription.current_period_end + timedelta(days=30) # or use plan billing_interval
|
||||||
|
|
||||||
|
# Clear renewal metadata
|
||||||
|
for key in ['renewal_invoice_id', 'renewal_required_at', 'grace_period_end',
|
||||||
|
'credit_reset_scheduled_at', 'advance_invoice_id', 'advance_invoice_created',
|
||||||
|
'auto_payment_failed']:
|
||||||
|
subscription.metadata.pop(key, None)
|
||||||
|
|
||||||
|
subscription.save()
|
||||||
|
|
||||||
|
logger.info(f"Subscription {subscription.id} renewal completed, credits reset to {subscription.plan.included_credits}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error completing renewal for subscription {subscription.id}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='billing.send_renewal_day_reminders')
|
||||||
|
def send_renewal_day_reminders():
|
||||||
|
"""
|
||||||
|
Send Day 0 renewal reminder for bank transfer users with unpaid invoices.
|
||||||
|
Run daily at 10:00.
|
||||||
|
|
||||||
|
Only sends if:
|
||||||
|
- Subscription is pending_renewal
|
||||||
|
- Invoice still unpaid
|
||||||
|
- It's the renewal day (Day 0)
|
||||||
"""
|
"""
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
reminder_threshold = now - timedelta(days=INVOICE_REMINDER_INTERVAL_DAYS)
|
|
||||||
|
|
||||||
# Get subscriptions pending renewal
|
# Get subscriptions pending renewal (set to pending_renewal on Day 0 at 00:05)
|
||||||
subscriptions = Subscription.objects.filter(
|
subscriptions = Subscription.objects.filter(
|
||||||
status='pending_renewal'
|
status='pending_renewal'
|
||||||
).select_related('account', 'plan')
|
).select_related('account', 'plan')
|
||||||
|
|
||||||
|
reminder_count = 0
|
||||||
for subscription in subscriptions:
|
for subscription in subscriptions:
|
||||||
# Check if enough time has passed since last reminder
|
# Only send if renewal was today (Day 0)
|
||||||
last_reminder = subscription.metadata.get('last_invoice_reminder_at')
|
renewal_required_at = subscription.metadata.get('renewal_required_at')
|
||||||
if last_reminder:
|
if not renewal_required_at:
|
||||||
|
continue
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
last_reminder_dt = datetime.fromisoformat(last_reminder.replace('Z', '+00:00'))
|
renewal_dt = datetime.fromisoformat(renewal_required_at.replace('Z', '+00:00'))
|
||||||
if hasattr(last_reminder_dt, 'tzinfo') and last_reminder_dt.tzinfo is None:
|
if hasattr(renewal_dt, 'tzinfo') and renewal_dt.tzinfo is None:
|
||||||
last_reminder_dt = timezone.make_aware(last_reminder_dt)
|
renewal_dt = timezone.make_aware(renewal_dt)
|
||||||
if last_reminder_dt > reminder_threshold:
|
|
||||||
|
# Only send Day 0 reminder on the actual renewal day
|
||||||
|
if renewal_dt.date() != now.date():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if Day 0 reminder already sent
|
||||||
|
if subscription.metadata.get('day0_reminder_sent'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get invoice
|
||||||
|
invoice_id = subscription.metadata.get('advance_invoice_id') or subscription.metadata.get('renewal_invoice_id')
|
||||||
|
if not invoice_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
invoice = Invoice.objects.get(id=invoice_id)
|
||||||
|
if invoice.status == 'paid':
|
||||||
|
continue # Already paid
|
||||||
|
|
||||||
|
BillingEmailService.send_invoice_email(
|
||||||
|
invoice,
|
||||||
|
is_reminder=True,
|
||||||
|
extra_context={'is_renewal_day': True, 'urgency': 'high'}
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription.metadata['day0_reminder_sent'] = True
|
||||||
|
subscription.metadata['day0_reminder_sent_at'] = now.isoformat()
|
||||||
|
subscription.save(update_fields=['metadata'])
|
||||||
|
|
||||||
|
reminder_count += 1
|
||||||
|
logger.info(f"Day 0 renewal reminder sent for subscription {subscription.id}")
|
||||||
|
|
||||||
|
except Invoice.DoesNotExist:
|
||||||
|
logger.warning(f"Invoice {invoice_id} not found for subscription {subscription.id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to send Day 0 reminder for subscription {subscription.id}: {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"Sent {reminder_count} Day 0 renewal reminders")
|
||||||
|
return reminder_count
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get the renewal invoice
|
# Get the renewal invoice
|
||||||
@@ -254,6 +460,117 @@ def check_expired_renewals():
|
|||||||
|
|
||||||
if expired_count > 0:
|
if expired_count > 0:
|
||||||
logger.info(f"Expired {expired_count} subscriptions due to non-payment")
|
logger.info(f"Expired {expired_count} subscriptions due to non-payment")
|
||||||
|
return expired_count
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy task - kept for backward compatibility, but functionality moved to send_day_after_reminders
|
||||||
|
@shared_task(name='billing.reset_unpaid_renewal_credits')
|
||||||
|
def reset_unpaid_renewal_credits():
|
||||||
|
"""
|
||||||
|
DEPRECATED: Credit reset is now handled by send_day_after_reminders task.
|
||||||
|
This task is kept for backward compatibility during transition.
|
||||||
|
"""
|
||||||
|
logger.info("reset_unpaid_renewal_credits is deprecated - credit reset handled by send_day_after_reminders")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='billing.send_day_after_reminders')
|
||||||
|
def send_day_after_reminders():
|
||||||
|
"""
|
||||||
|
Send Day +1 urgent reminders AND reset credits for still-unpaid invoices.
|
||||||
|
Run daily at 09:15.
|
||||||
|
|
||||||
|
Combined task that:
|
||||||
|
1. Sends urgent Day +1 reminder email for bank transfer users
|
||||||
|
2. Resets plan credits to 0 if not paid after 24 hours
|
||||||
|
"""
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
subscriptions = Subscription.objects.filter(
|
||||||
|
status='pending_renewal'
|
||||||
|
).select_related('account', 'plan')
|
||||||
|
|
||||||
|
reminder_count = 0
|
||||||
|
reset_count = 0
|
||||||
|
|
||||||
|
for subscription in subscriptions:
|
||||||
|
renewal_required_at = subscription.metadata.get('renewal_required_at')
|
||||||
|
if not renewal_required_at:
|
||||||
|
continue
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
renewal_dt = datetime.fromisoformat(renewal_required_at.replace('Z', '+00:00'))
|
||||||
|
if hasattr(renewal_dt, 'tzinfo') and renewal_dt.tzinfo is None:
|
||||||
|
renewal_dt = timezone.make_aware(renewal_dt)
|
||||||
|
|
||||||
|
# Check if it's been about 1 day since renewal (Day +1)
|
||||||
|
time_since_renewal = now - renewal_dt
|
||||||
|
if time_since_renewal < timedelta(hours=23) or time_since_renewal > timedelta(hours=48):
|
||||||
|
continue
|
||||||
|
|
||||||
|
invoice_id = subscription.metadata.get('renewal_invoice_id') or subscription.metadata.get('advance_invoice_id')
|
||||||
|
|
||||||
|
# 1. Send Day +1 urgent reminder (if not sent)
|
||||||
|
if not subscription.metadata.get('day_after_reminder_sent') and invoice_id:
|
||||||
|
try:
|
||||||
|
invoice = Invoice.objects.get(id=invoice_id)
|
||||||
|
|
||||||
|
if invoice.status in ['pending', 'overdue']:
|
||||||
|
BillingEmailService.send_invoice_email(
|
||||||
|
invoice,
|
||||||
|
is_reminder=True,
|
||||||
|
extra_context={
|
||||||
|
'is_day_after_reminder': True,
|
||||||
|
'credits_reset_warning': True,
|
||||||
|
'urgent': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription.metadata['day_after_reminder_sent'] = True
|
||||||
|
subscription.metadata['day_after_reminder_sent_at'] = now.isoformat()
|
||||||
|
subscription.save(update_fields=['metadata'])
|
||||||
|
|
||||||
|
reminder_count += 1
|
||||||
|
logger.info(f"Day +1 urgent reminder sent for subscription {subscription.id}")
|
||||||
|
|
||||||
|
except Invoice.DoesNotExist:
|
||||||
|
logger.warning(f"Invoice {invoice_id} not found for day-after reminder")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to send day-after reminder for subscription {subscription.id}: {str(e)}")
|
||||||
|
|
||||||
|
# 2. Reset credits to 0 (if not already reset)
|
||||||
|
if not subscription.metadata.get('credits_reset_for_nonpayment'):
|
||||||
|
try:
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
|
||||||
|
# Reset plan credits to 0 (bonus credits NOT affected)
|
||||||
|
current_credits = subscription.account.credits
|
||||||
|
if current_credits > 0:
|
||||||
|
CreditService.reset_credits_for_renewal(
|
||||||
|
account=subscription.account,
|
||||||
|
new_amount=0,
|
||||||
|
description='Credits reset due to unpaid renewal (Day +1)',
|
||||||
|
metadata={
|
||||||
|
'subscription_id': subscription.id,
|
||||||
|
'plan_id': subscription.plan.id if subscription.plan else None,
|
||||||
|
'reset_reason': 'nonpayment_day_after',
|
||||||
|
'previous_credits': current_credits
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as reset
|
||||||
|
subscription.metadata['credits_reset_for_nonpayment'] = True
|
||||||
|
subscription.metadata['credits_reset_at'] = now.isoformat()
|
||||||
|
subscription.save(update_fields=['metadata'])
|
||||||
|
|
||||||
|
reset_count += 1
|
||||||
|
logger.info(f"Credits reset to 0 for unpaid subscription {subscription.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to reset credits for subscription {subscription.id}: {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"Day +1: Sent {reminder_count} urgent reminders, reset credits for {reset_count} subscriptions")
|
||||||
|
return {'reminders_sent': reminder_count, 'credits_reset': reset_count}
|
||||||
|
|
||||||
|
|
||||||
def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool:
|
def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool:
|
||||||
|
|||||||
@@ -22,8 +22,21 @@ app.autodiscover_tasks()
|
|||||||
# Explicitly import tasks from igny8_core/tasks directory
|
# Explicitly import tasks from igny8_core/tasks directory
|
||||||
app.autodiscover_tasks(['igny8_core.tasks'])
|
app.autodiscover_tasks(['igny8_core.tasks'])
|
||||||
|
|
||||||
|
# Register billing tasks after Django is ready
|
||||||
|
@app.on_after_finalize.connect
|
||||||
|
def setup_billing_tasks(sender, **kwargs):
|
||||||
|
"""Import billing tasks after Django apps are loaded."""
|
||||||
|
try:
|
||||||
|
import igny8_core.business.billing.tasks.invoice_lifecycle
|
||||||
|
import igny8_core.business.billing.tasks.subscription_renewal
|
||||||
|
except ImportError as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(f"Failed to import billing tasks: {e}")
|
||||||
|
|
||||||
# Celery Beat schedule for periodic tasks
|
# Celery Beat schedule for periodic tasks
|
||||||
|
# NOTE: Do NOT set static task_id in options - it causes results to overwrite instead of creating history
|
||||||
app.conf.beat_schedule = {
|
app.conf.beat_schedule = {
|
||||||
|
# Monthly Credits
|
||||||
'replenish-monthly-credits': {
|
'replenish-monthly-credits': {
|
||||||
'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits',
|
'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits',
|
||||||
'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight
|
'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight
|
||||||
@@ -38,21 +51,37 @@ app.conf.beat_schedule = {
|
|||||||
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users
|
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users
|
||||||
},
|
},
|
||||||
# Subscription Renewal Tasks
|
# Subscription Renewal Tasks
|
||||||
'send-renewal-notices': {
|
# Stripe/PayPal: No advance notice (industry standard) - auto-pay happens on renewal day
|
||||||
'task': 'billing.send_renewal_notices',
|
# Bank Transfer: Invoice created 3 days before, reminders on Day 0 and Day +1
|
||||||
'schedule': crontab(hour=9, minute=0), # Daily at 09:00
|
'create-bank-transfer-invoices': {
|
||||||
|
'task': 'billing.create_bank_transfer_invoices',
|
||||||
|
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 - creates invoices 3 days before renewal
|
||||||
},
|
},
|
||||||
'process-subscription-renewals': {
|
'process-subscription-renewals': {
|
||||||
'task': 'billing.process_subscription_renewals',
|
'task': 'billing.process_subscription_renewals',
|
||||||
'schedule': crontab(hour=0, minute=5), # Daily at 00:05
|
'schedule': crontab(hour=0, minute=5), # Daily at 00:05 - auto-pay for Stripe/PayPal
|
||||||
},
|
},
|
||||||
'send-invoice-reminders': {
|
'send-renewal-day-reminders': {
|
||||||
'task': 'billing.send_invoice_reminders',
|
'task': 'billing.send_renewal_day_reminders',
|
||||||
'schedule': crontab(hour=10, minute=0), # Daily at 10:00
|
'schedule': crontab(hour=10, minute=0), # Daily at 10:00 - Day 0 reminder for bank transfer
|
||||||
},
|
},
|
||||||
'check-expired-renewals': {
|
'check-expired-renewals': {
|
||||||
'task': 'billing.check_expired_renewals',
|
'task': 'billing.check_expired_renewals',
|
||||||
'schedule': crontab(hour=0, minute=15), # Daily at 00:15
|
'schedule': crontab(hour=0, minute=15), # Daily at 00:15 - expire after 7 days
|
||||||
|
},
|
||||||
|
# Send day-after reminders + reset credits for unpaid bank transfer renewals
|
||||||
|
'send-day-after-reminders': {
|
||||||
|
'task': 'billing.send_day_after_reminders',
|
||||||
|
'schedule': crontab(hour=9, minute=15), # Daily at 09:15 - Day +1 urgent + credit reset
|
||||||
|
},
|
||||||
|
# Credit Invoice Lifecycle Tasks
|
||||||
|
'send-credit-invoice-expiry-reminders': {
|
||||||
|
'task': 'billing.send_credit_invoice_expiry_reminders',
|
||||||
|
'schedule': crontab(hour=9, minute=30), # Daily at 09:30
|
||||||
|
},
|
||||||
|
'void-expired-credit-invoices': {
|
||||||
|
'task': 'billing.void_expired_credit_invoices',
|
||||||
|
'schedule': crontab(hour=0, minute=45), # Daily at 00:45
|
||||||
},
|
},
|
||||||
# Automation Tasks
|
# Automation Tasks
|
||||||
'check-scheduled-automations': {
|
'check-scheduled-automations': {
|
||||||
@@ -61,7 +90,7 @@ app.conf.beat_schedule = {
|
|||||||
},
|
},
|
||||||
'check-test-triggers': {
|
'check-test-triggers': {
|
||||||
'task': 'automation.check_test_triggers',
|
'task': 'automation.check_test_triggers',
|
||||||
'schedule': crontab(minute='*'), # Every minute (task self-checks if any test mode enabled)
|
'schedule': crontab(minute='*/5'), # Every 5 minutes (task self-checks if any test mode enabled)
|
||||||
},
|
},
|
||||||
# Publishing Scheduler Tasks
|
# Publishing Scheduler Tasks
|
||||||
'schedule-approved-content': {
|
'schedule-approved-content': {
|
||||||
|
|||||||
@@ -16,12 +16,35 @@ from igny8_core.business.billing.models import (
|
|||||||
PaymentMethodConfig,
|
PaymentMethodConfig,
|
||||||
PlanLimitUsage,
|
PlanLimitUsage,
|
||||||
AIModelConfig,
|
AIModelConfig,
|
||||||
|
WebhookEvent,
|
||||||
)
|
)
|
||||||
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
|
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
|
||||||
from import_export.admin import ExportMixin, ImportExportMixin
|
from import_export.admin import ExportMixin, ImportExportMixin
|
||||||
from import_export import resources
|
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):
|
class CreditTransactionResource(resources.ModelResource):
|
||||||
"""Resource class for exporting Credit Transactions"""
|
"""Resource class for exporting Credit Transactions"""
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -273,14 +296,16 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
invoice.paid_at = timezone.now()
|
invoice.paid_at = timezone.now()
|
||||||
invoice.save()
|
invoice.save()
|
||||||
|
|
||||||
# Update Subscription
|
invoice_type = _get_invoice_type(invoice)
|
||||||
if subscription and subscription.status != 'active':
|
|
||||||
|
# Update Subscription (subscription invoices only)
|
||||||
|
if invoice_type == 'subscription' and subscription and subscription.status != 'active':
|
||||||
subscription.status = 'active'
|
subscription.status = 'active'
|
||||||
subscription.external_payment_id = obj.manual_reference
|
subscription.external_payment_id = obj.manual_reference
|
||||||
subscription.save()
|
subscription.save()
|
||||||
|
|
||||||
# Update Account
|
# Update Account (subscription invoices only)
|
||||||
if account.status != 'active':
|
if invoice_type == 'subscription' and account.status != 'active':
|
||||||
account.status = 'active'
|
account.status = 'active'
|
||||||
account.save()
|
account.save()
|
||||||
|
|
||||||
@@ -296,6 +321,28 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
plan_name = ''
|
plan_name = ''
|
||||||
is_renewal = False
|
is_renewal = False
|
||||||
|
|
||||||
|
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,
|
||||||
|
description=f'Credit package: {package.name} ({credits_to_add} bonus credits) - Invoice {invoice.invoice_number}',
|
||||||
|
metadata={
|
||||||
|
'invoice_id': invoice.id,
|
||||||
|
'payment_id': obj.id,
|
||||||
|
'credit_package_id': str(package.id),
|
||||||
|
'approved_by': request.user.email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f'✓ Credit package approved: {credits_to_add} bonus credits added (never expire)',
|
||||||
|
level='SUCCESS'
|
||||||
|
)
|
||||||
|
else:
|
||||||
if subscription and subscription.plan:
|
if subscription and subscription.plan:
|
||||||
credits_to_add = subscription.plan.included_credits
|
credits_to_add = subscription.plan.included_credits
|
||||||
plan_name = subscription.plan.name
|
plan_name = subscription.plan.name
|
||||||
@@ -400,13 +447,16 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
invoice.paid_at = timezone.now()
|
invoice.paid_at = timezone.now()
|
||||||
invoice.save()
|
invoice.save()
|
||||||
|
|
||||||
# Update Subscription
|
invoice_type = _get_invoice_type(invoice)
|
||||||
if subscription:
|
|
||||||
|
# Update Subscription (subscription invoices only)
|
||||||
|
if invoice_type == 'subscription' and subscription:
|
||||||
subscription.status = 'active'
|
subscription.status = 'active'
|
||||||
subscription.external_payment_id = payment.manual_reference
|
subscription.external_payment_id = payment.manual_reference
|
||||||
subscription.save()
|
subscription.save()
|
||||||
|
|
||||||
# Update Account
|
# Update Account (subscription invoices only)
|
||||||
|
if invoice_type == 'subscription' and account.status != 'active':
|
||||||
account.status = 'active'
|
account.status = 'active'
|
||||||
account.save()
|
account.save()
|
||||||
|
|
||||||
@@ -422,7 +472,23 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
is_renewal = previous_paid_invoices
|
is_renewal = previous_paid_invoices
|
||||||
|
|
||||||
credits_added = 0
|
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
|
credits_added = subscription.plan.included_credits
|
||||||
if is_renewal:
|
if is_renewal:
|
||||||
# Renewal: Reset credits to full plan amount
|
# Renewal: Reset credits to full plan amount
|
||||||
@@ -1050,3 +1116,167 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
|||||||
count = queryset.update(is_testing=False)
|
count = queryset.update(is_testing=False)
|
||||||
self.message_user(request, f'{count} model(s) unmarked as testing.', messages.SUCCESS)
|
self.message_user(request, f'{count} model(s) unmarked as testing.', messages.SUCCESS)
|
||||||
unset_testing.short_description = 'Unset testing flag'
|
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.
|
Runs on the first day of each month at midnight.
|
||||||
|
|
||||||
For each active account with a plan:
|
For each active account with a plan:
|
||||||
- Adds plan.included_credits to account.credits
|
- Resets credits to plan.included_credits
|
||||||
- Creates a CreditTransaction record
|
- Creates a CreditTransaction record
|
||||||
- Logs the replenishment
|
- Logs the replenishment
|
||||||
"""
|
"""
|
||||||
@@ -52,12 +52,11 @@ def replenish_monthly_credits():
|
|||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Add credits using CreditService
|
# Reset credits using CreditService
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
new_balance = CreditService.add_credits(
|
new_balance = CreditService.reset_credits_for_renewal(
|
||||||
account=account,
|
account=account,
|
||||||
amount=monthly_credits,
|
new_amount=monthly_credits,
|
||||||
transaction_type='subscription',
|
|
||||||
description=f"Monthly credit replenishment - {plan.name} plan",
|
description=f"Monthly credit replenishment - {plan.name} plan",
|
||||||
metadata={
|
metadata={
|
||||||
'plan_id': plan.id,
|
'plan_id': plan.id,
|
||||||
@@ -69,7 +68,7 @@ def replenish_monthly_credits():
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Account {account.id} ({account.name}): "
|
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
|
replenished += 1
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
|||||||
"""
|
"""
|
||||||
ViewSet for credit balance operations
|
ViewSet for credit balance operations
|
||||||
Unified API Standard v1.0 compliant
|
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]
|
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||||
authentication_classes = [JWTAuthentication]
|
authentication_classes = [JWTAuthentication]
|
||||||
@@ -52,6 +60,8 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
|||||||
if not account:
|
if not account:
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
'credits': 0,
|
'credits': 0,
|
||||||
|
'bonus_credits': 0,
|
||||||
|
'total_credits': 0,
|
||||||
'plan_credits_per_month': 0,
|
'plan_credits_per_month': 0,
|
||||||
'credits_used_this_month': 0,
|
'credits_used_this_month': 0,
|
||||||
'credits_remaining': 0,
|
'credits_remaining': 0,
|
||||||
@@ -70,20 +80,25 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
|||||||
created_at__gte=start_of_month
|
created_at__gte=start_of_month
|
||||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||||
|
|
||||||
|
# Plan credits (reset on renewal)
|
||||||
credits = account.credits or 0
|
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 = {
|
data = {
|
||||||
'credits': credits,
|
'credits': credits,
|
||||||
|
'bonus_credits': bonus_credits,
|
||||||
|
'total_credits': total_credits,
|
||||||
'plan_credits_per_month': plan_credits_per_month,
|
'plan_credits_per_month': plan_credits_per_month,
|
||||||
'credits_used_this_month': credits_used_this_month,
|
'credits_used_this_month': credits_used_this_month,
|
||||||
'credits_remaining': credits_remaining,
|
'credits_remaining': credits_remaining,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate and serialize data
|
# Validate and serialize data (skip serializer for now due to new fields)
|
||||||
serializer = CreditBalanceSerializer(data=data)
|
return success_response(data=data, request=request)
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return success_response(data=serializer.validated_data, request=request)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
@@ -682,7 +697,12 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
|||||||
invoice.paid_at = timezone.now()
|
invoice.paid_at = timezone.now()
|
||||||
invoice.save()
|
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
|
subscription = None
|
||||||
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
|
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
|
||||||
subscription = invoice.subscription
|
subscription = invoice.subscription
|
||||||
@@ -692,18 +712,37 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if subscription:
|
if invoice_type == 'subscription' and subscription:
|
||||||
subscription.status = 'active'
|
subscription.status = 'active'
|
||||||
subscription.external_payment_id = payment.manual_reference
|
subscription.external_payment_id = payment.manual_reference
|
||||||
subscription.save(update_fields=['status', 'external_payment_id'])
|
subscription.save(update_fields=['status', 'external_payment_id'])
|
||||||
|
|
||||||
# 4. CRITICAL: Set account status to active
|
# 4. Set account status to active (subscription invoices only)
|
||||||
|
if invoice_type == 'subscription' and account.status != 'active':
|
||||||
account.status = 'active'
|
account.status = 'active'
|
||||||
account.save(update_fields=['status'])
|
account.save(update_fields=['status'])
|
||||||
|
|
||||||
# 5. Add credits if plan has included credits
|
# 5. Add/Reset credits based on invoice type
|
||||||
credits_added = 0
|
credits_added = 0
|
||||||
try:
|
try:
|
||||||
|
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
|
plan = None
|
||||||
if subscription and subscription.plan:
|
if subscription and subscription.plan:
|
||||||
plan = subscription.plan
|
plan = subscription.plan
|
||||||
@@ -712,10 +751,9 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
if plan and plan.included_credits > 0:
|
if plan and plan.included_credits > 0:
|
||||||
credits_added = plan.included_credits
|
credits_added = plan.included_credits
|
||||||
CreditService.add_credits(
|
CreditService.reset_credits_for_renewal(
|
||||||
account=account,
|
account=account,
|
||||||
amount=credits_added,
|
new_amount=credits_added,
|
||||||
transaction_type='subscription',
|
|
||||||
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number if invoice else "N/A"}',
|
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number if invoice else "N/A"}',
|
||||||
metadata={
|
metadata={
|
||||||
'subscription_id': subscription.id if subscription else None,
|
'subscription_id': subscription.id if subscription else None,
|
||||||
@@ -727,11 +765,33 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
|||||||
)
|
)
|
||||||
except Exception as credit_error:
|
except Exception as credit_error:
|
||||||
logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True)
|
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(
|
logger.info(
|
||||||
f'Payment approved: Payment {payment.id}, Account {account.id} set to active, '
|
f'Payment approved: Payment {payment.id}, Account {account.id}, '
|
||||||
f'{credits_added} credits added'
|
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
|
# 6. Send approval email
|
||||||
@@ -783,6 +843,24 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
logger.info(f'Payment rejected: Payment {payment.id}, Reason: {rejection_reason}')
|
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
|
# Send rejection email
|
||||||
try:
|
try:
|
||||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||||
|
|||||||
@@ -550,7 +550,9 @@ CELERY_REDIS_BACKEND_USE_SSL = os.getenv('REDIS_SSL_ENABLED', 'false').lower() =
|
|||||||
|
|
||||||
# Publish/Sync Logging Configuration
|
# Publish/Sync Logging Configuration
|
||||||
PUBLISH_SYNC_LOG_DIR = os.path.join(BASE_DIR, 'logs', 'publish-sync-logs')
|
PUBLISH_SYNC_LOG_DIR = os.path.join(BASE_DIR, 'logs', 'publish-sync-logs')
|
||||||
|
BILLING_LOG_DIR = os.path.join(BASE_DIR, 'logs', 'billing-logs')
|
||||||
os.makedirs(PUBLISH_SYNC_LOG_DIR, exist_ok=True)
|
os.makedirs(PUBLISH_SYNC_LOG_DIR, exist_ok=True)
|
||||||
|
os.makedirs(BILLING_LOG_DIR, exist_ok=True)
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
'version': 1,
|
||||||
@@ -566,6 +568,11 @@ LOGGING = {
|
|||||||
'style': '{',
|
'style': '{',
|
||||||
'datefmt': '%Y-%m-%d %H:%M:%S',
|
'datefmt': '%Y-%m-%d %H:%M:%S',
|
||||||
},
|
},
|
||||||
|
'billing': {
|
||||||
|
'format': '[{asctime}] [{levelname}] [{name}] {message}',
|
||||||
|
'style': '{',
|
||||||
|
'datefmt': '%Y-%m-%d %H:%M:%S',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'handlers': {
|
'handlers': {
|
||||||
'console': {
|
'console': {
|
||||||
@@ -593,6 +600,20 @@ LOGGING = {
|
|||||||
'backupCount': 10,
|
'backupCount': 10,
|
||||||
'formatter': 'publish_sync',
|
'formatter': 'publish_sync',
|
||||||
},
|
},
|
||||||
|
'billing_file': {
|
||||||
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
|
'filename': os.path.join(BILLING_LOG_DIR, 'billing.log'),
|
||||||
|
'maxBytes': 10 * 1024 * 1024, # 10 MB
|
||||||
|
'backupCount': 20,
|
||||||
|
'formatter': 'billing',
|
||||||
|
},
|
||||||
|
'payment_file': {
|
||||||
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
|
'filename': os.path.join(BILLING_LOG_DIR, 'payments.log'),
|
||||||
|
'maxBytes': 10 * 1024 * 1024, # 10 MB
|
||||||
|
'backupCount': 20,
|
||||||
|
'formatter': 'billing',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'loggers': {
|
'loggers': {
|
||||||
'publish_sync': {
|
'publish_sync': {
|
||||||
@@ -610,6 +631,16 @@ LOGGING = {
|
|||||||
'level': 'INFO',
|
'level': 'INFO',
|
||||||
'propagate': False,
|
'propagate': False,
|
||||||
},
|
},
|
||||||
|
'billing': {
|
||||||
|
'handlers': ['console', 'billing_file'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
|
'payments': {
|
||||||
|
'handlers': ['console', 'payment_file'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
'auth.middleware': {
|
'auth.middleware': {
|
||||||
'handlers': ['console'],
|
'handlers': ['console'],
|
||||||
'level': 'INFO',
|
'level': 'INFO',
|
||||||
@@ -626,6 +657,7 @@ LOGGING = {
|
|||||||
# Celery Results Backend
|
# Celery Results Backend
|
||||||
CELERY_RESULT_BACKEND = 'django-db'
|
CELERY_RESULT_BACKEND = 'django-db'
|
||||||
CELERY_CACHE_BACKEND = 'django-cache'
|
CELERY_CACHE_BACKEND = 'django-cache'
|
||||||
|
CELERY_RESULT_EXTENDED = True # Store task name, args, kwargs in results
|
||||||
|
|
||||||
# Import/Export Settings
|
# Import/Export Settings
|
||||||
IMPORT_EXPORT_USE_TRANSACTIONS = True
|
IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||||
@@ -704,6 +736,7 @@ UNFOLD = {
|
|||||||
{"title": "Credit Packages", "icon": "card_giftcard", "link": lambda request: "/admin/billing/creditpackage/"},
|
{"title": "Credit Packages", "icon": "card_giftcard", "link": lambda request: "/admin/billing/creditpackage/"},
|
||||||
{"title": "Payment Methods (Global)", "icon": "credit_card", "link": lambda request: "/admin/billing/paymentmethodconfig/"},
|
{"title": "Payment Methods (Global)", "icon": "credit_card", "link": lambda request: "/admin/billing/paymentmethodconfig/"},
|
||||||
{"title": "Account Payment Methods", "icon": "account_balance_wallet", "link": lambda request: "/admin/billing/accountpaymentmethod/"},
|
{"title": "Account Payment Methods", "icon": "account_balance_wallet", "link": lambda request: "/admin/billing/accountpaymentmethod/"},
|
||||||
|
{"title": "Payment Logs", "icon": "receipt", "link": lambda request: "/admin/billing/webhookevent/"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
# Credits & AI Usage (CONSOLIDATED)
|
# Credits & AI Usage (CONSOLIDATED)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Invoice Cancelled</h2>
|
||||||
|
<p>Hi {{ account_name }},</p>
|
||||||
|
<p>Your credit invoice <strong>#{{ invoice_number }}</strong> was cancelled.</p>
|
||||||
|
<p>
|
||||||
|
You can create a new credit purchase anytime from your billing page:
|
||||||
|
<a href="{{ invoice_url }}">Billing</a>
|
||||||
|
</p>
|
||||||
|
<p>If you have any questions, contact support at {{ support_email }}.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Invoice Expired</h2>
|
||||||
|
<p>Hi {{ account_name }},</p>
|
||||||
|
<p>Your credit invoice <strong>#{{ invoice_number }}</strong> has expired and was voided.</p>
|
||||||
|
<p>
|
||||||
|
You can create a new credit purchase anytime from your billing page:
|
||||||
|
<a href="{{ invoice_url }}">Billing</a>
|
||||||
|
</p>
|
||||||
|
<p>If you have any questions, contact support at {{ support_email }}.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Invoice Expiring Soon</h2>
|
||||||
|
<p>Hi {{ account_name }},</p>
|
||||||
|
<p>Your credit invoice <strong>#{{ invoice_number }}</strong> will expire soon.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Amount:</strong> {{ currency }} {{ total_amount }}</li>
|
||||||
|
<li><strong>Expires at:</strong> {{ expires_at }}</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Please complete payment before it expires:
|
||||||
|
<a href="{{ invoice_url }}">View Invoice</a>
|
||||||
|
</p>
|
||||||
|
<p>If you have any questions, contact support at {{ support_email }}.</p>
|
||||||
|
{% endblock %}
|
||||||
754
docs/10-MODULES/BILLING-PAYMENTS-COMPLETE.md
Normal file
754
docs/10-MODULES/BILLING-PAYMENTS-COMPLETE.md
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
# IGNY8 Billing & Payments - Complete Reference
|
||||||
|
|
||||||
|
> **Last Updated:** January 20, 2026
|
||||||
|
> **Version:** 2.0 (Two-Pool Credit System)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [System Overview](#1-system-overview)
|
||||||
|
2. [Credit System](#2-credit-system)
|
||||||
|
3. [Payment Methods](#3-payment-methods)
|
||||||
|
4. [Subscription Lifecycle](#4-subscription-lifecycle)
|
||||||
|
5. [Credit Packages](#5-credit-packages)
|
||||||
|
6. [Invoice System](#6-invoice-system)
|
||||||
|
7. [Renewal Workflow](#7-renewal-workflow)
|
||||||
|
8. [Admin Operations](#8-admin-operations)
|
||||||
|
9. [API Reference](#9-api-reference)
|
||||||
|
10. [Database Schema](#10-database-schema)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System Overview
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ IGNY8 BILLING SYSTEM │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ STRIPE │ │ PAYPAL │ │ BANK TRANSFER│ │
|
||||||
|
│ │ (Card/Intl) │ │ (Intl) │ │ (PK Only) │ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ PAYMENT GATEWAY LAYER │ │
|
||||||
|
│ │ • Webhook Processing • Payment Verification • Logging │ │
|
||||||
|
│ └─────────────────────────────┬───────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ INVOICE SERVICE │ │
|
||||||
|
│ │ • Create Invoice • Update Status • PDF Generation │ │
|
||||||
|
│ └─────────────────────────────┬───────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ CREDIT SERVICE │ │
|
||||||
|
│ │ • Plan Credits • Bonus Credits • Deduction • Reset │ │
|
||||||
|
│ └─────────────────────────────┬───────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ACCOUNT │ │
|
||||||
|
│ │ credits (plan) │ bonus_credits (purchased) │ status │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
| Component | File Path |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Credit Service | `business/billing/services/credit_service.py` |
|
||||||
|
| Invoice Service | `business/billing/services/invoice_service.py` |
|
||||||
|
| Payment Service | `business/billing/services/payment_service.py` |
|
||||||
|
| Email Service | `business/billing/services/email_service.py` |
|
||||||
|
| Stripe Webhooks | `business/billing/views/stripe_views.py` |
|
||||||
|
| PayPal Webhooks | `business/billing/views/paypal_views.py` |
|
||||||
|
| Subscription Renewal | `business/billing/tasks/subscription_renewal.py` |
|
||||||
|
| Invoice Lifecycle | `business/billing/tasks/invoice_lifecycle.py` |
|
||||||
|
| Billing Admin | `modules/billing/admin.py` |
|
||||||
|
| Billing Models | `business/billing/models.py` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Credit System
|
||||||
|
|
||||||
|
### Two-Pool Credit Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ ACCOUNT CREDITS │
|
||||||
|
├────────────────────────────┬────────────────────────────────┤
|
||||||
|
│ PLAN CREDITS │ BONUS CREDITS │
|
||||||
|
│ (account.credits) │ (account.bonus_credits) │
|
||||||
|
├────────────────────────────┼────────────────────────────────┤
|
||||||
|
│ • From subscription plan │ • From credit packages │
|
||||||
|
│ • Resets on renewal │ • NEVER expire │
|
||||||
|
│ • Used FIRST │ • Used SECOND (after plan=0) │
|
||||||
|
│ • Max = plan.included_ │ • No maximum limit │
|
||||||
|
│ credits │ │
|
||||||
|
└────────────────────────────┴────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credit Deduction Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐
|
||||||
|
│ CREDIT REQUEST │
|
||||||
|
│ (e.g., 50) │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ Check Total │
|
||||||
|
│ credits + bonus │
|
||||||
|
│ >= requested? │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌──────────────┴──────────────┐
|
||||||
|
│ NO │ YES
|
||||||
|
▼ ▼
|
||||||
|
┌────────────────┐ ┌────────────────────┐
|
||||||
|
│ INSUFFICIENT │ │ Plan Credits >= 50?│
|
||||||
|
│ Return False │ └─────────┬──────────┘
|
||||||
|
└────────────────┘ │
|
||||||
|
┌──────────┴──────────┐
|
||||||
|
│ YES │ NO
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────┐ ┌────────────────────┐
|
||||||
|
│ Deduct from │ │ Deduct plan credits│
|
||||||
|
│ plan credits │ │ to 0, remainder │
|
||||||
|
│ only │ │ from bonus_credits │
|
||||||
|
└───────────────┘ └────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credit Operations
|
||||||
|
|
||||||
|
| Operation | Method | Affects | Description |
|
||||||
|
|-----------|--------|---------|-------------|
|
||||||
|
| Add Plan Credits | `add_credits()` | `credits` | Initial subscription, manual adjustment |
|
||||||
|
| Add Bonus Credits | `add_bonus_credits()` | `bonus_credits` | Credit package purchases |
|
||||||
|
| Deduct Credits | `deduct_credits()` | Both pools | AI operations, uses plan first |
|
||||||
|
| Reset on Renewal | `reset_credits_for_renewal()` | `credits` only | Sets plan credits to new amount |
|
||||||
|
| Check Balance | `check_credits()` | Read-only | Returns total available |
|
||||||
|
|
||||||
|
### Credit Transaction Types
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `subscription` | Plan credits from subscription |
|
||||||
|
| `purchase` | Bonus credits from credit package |
|
||||||
|
| `usage` | Credit consumption for AI operations |
|
||||||
|
| `refund` | Credits returned due to failed operation |
|
||||||
|
| `manual` | Admin adjustment |
|
||||||
|
| `renewal` | Reset during subscription renewal |
|
||||||
|
| `bonus` | Promotional bonus credits |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Payment Methods
|
||||||
|
|
||||||
|
### Payment Method by Country
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ PAYMENT METHOD MATRIX │
|
||||||
|
├─────────────────┬───────────────────────────────────────────┤
|
||||||
|
│ COUNTRY │ AVAILABLE METHODS │
|
||||||
|
├─────────────────┼───────────────────────────────────────────┤
|
||||||
|
│ Pakistan (PK) │ ✅ Bank Transfer ✅ Stripe (Card) │
|
||||||
|
│ │ ❌ PayPal (not available) │
|
||||||
|
├─────────────────┼───────────────────────────────────────────┤
|
||||||
|
│ Other (Intl) │ ✅ Stripe (Card) ✅ PayPal │
|
||||||
|
│ │ ❌ Bank Transfer (not available) │
|
||||||
|
└─────────────────┴───────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment Flow by Method
|
||||||
|
|
||||||
|
#### Stripe (Card) Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
|
||||||
|
│ User │────▶│ Frontend │────▶│ Create │────▶│ Stripe │
|
||||||
|
│ Clicks │ │ Checkout │ │ Checkout │ │ Hosted │
|
||||||
|
│ Pay │ │ │ │ Session │ │ Page │
|
||||||
|
└─────────┘ └──────────┘ └─────────────┘ └────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
|
||||||
|
│ Account │◀────│ Credit │◀────│ Invoice │◀────│ Webhook │
|
||||||
|
│ Active │ │ Added │ │ Paid │ │ Received │
|
||||||
|
└─────────┘ └──────────┘ └─────────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PayPal Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
|
||||||
|
│ User │────▶│ Frontend │────▶│ Create │────▶│ PayPal │
|
||||||
|
│ Clicks │ │ Checkout │ │ Order/Sub │ │ Hosted │
|
||||||
|
│ Pay │ │ │ │ │ │ Page │
|
||||||
|
└─────────┘ └──────────┘ └─────────────┘ └────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
|
||||||
|
│ Account │◀────│ Credit │◀────│ Invoice │◀────│ Webhook │
|
||||||
|
│ Active │ │ Added │ │ Paid │ │ CAPTURE │
|
||||||
|
└─────────┘ └──────────┘ └─────────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Bank Transfer Flow (PK Only)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
|
||||||
|
│ User │────▶│ View │────▶│ Manual │────▶│ Upload │
|
||||||
|
│ Selects │ │ Bank │ │ Transfer │ │ Proof │
|
||||||
|
│ Bank │ │ Details │ │ to Bank │ │ │
|
||||||
|
└─────────┘ └──────────┘ └─────────────┘ └────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
|
||||||
|
│ Account │◀────│ Credit │◀────│ Admin │◀────│ Payment │
|
||||||
|
│ Active │ │ Added │ │ Approves │ │ Created │
|
||||||
|
└─────────┘ └──────────┘ └─────────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Subscription Lifecycle
|
||||||
|
|
||||||
|
### Subscription States
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SUBSCRIPTION STATE MACHINE │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────┐
|
||||||
|
│ NEW │
|
||||||
|
│ SIGNUP │
|
||||||
|
└────┬────┘
|
||||||
|
│ Create subscription + invoice
|
||||||
|
▼
|
||||||
|
┌─────────────┐ Payment Failed ┌─────────────┐
|
||||||
|
│ PENDING │─────────────────────────▶│ FAILED │
|
||||||
|
│ (awaiting │ │ │
|
||||||
|
│ payment) │ └─────────────┘
|
||||||
|
└──────┬──────┘
|
||||||
|
│ Payment Success
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ ACTIVE │◀─────────────────────────────────────┐
|
||||||
|
│ │ Renewal Payment Success │
|
||||||
|
└──────┬──────┘ │
|
||||||
|
│ Renewal Date │
|
||||||
|
▼ │
|
||||||
|
┌─────────────┐ Payment Within ┌───────────┴─┐
|
||||||
|
│ PENDING │ Grace Period │ │
|
||||||
|
│ RENEWAL │────────────────────────▶│ ACTIVE │
|
||||||
|
│ │ │ (renewed) │
|
||||||
|
└──────┬──────┘ └─────────────┘
|
||||||
|
│ Grace Period Expired (7 days)
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ EXPIRED │ Manual Reactivation
|
||||||
|
│ │────────────────────────▶ Back to PENDING
|
||||||
|
└─────────────┘
|
||||||
|
|
||||||
|
┌─────────────┐
|
||||||
|
│ CANCELLED │ User-initiated cancellation
|
||||||
|
│ │ (end of current period)
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subscription Status Reference
|
||||||
|
|
||||||
|
| Status | Credits Access | Can Use Features | Next Action |
|
||||||
|
|--------|----------------|------------------|-------------|
|
||||||
|
| `pending` | ❌ No | ❌ No | Complete payment |
|
||||||
|
| `active` | ✅ Yes | ✅ Yes | None (auto-renews) |
|
||||||
|
| `pending_renewal` | ✅ Yes (24h) | ✅ Yes | Pay invoice |
|
||||||
|
| `expired` | ❌ No | ❌ Limited | Resubscribe |
|
||||||
|
| `cancelled` | ✅ Until end | ✅ Until end | None |
|
||||||
|
| `failed` | ❌ No | ❌ No | Retry payment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Credit Packages
|
||||||
|
|
||||||
|
### Available Packages
|
||||||
|
|
||||||
|
| Package | Credits | USD Price | PKR Price | Per Credit |
|
||||||
|
|---------|---------|-----------|-----------|------------|
|
||||||
|
| Starter | 500 | $50.00 | ≈ PKR 14,000 | $0.10 |
|
||||||
|
| Growth | 2,000 | $200.00 | ≈ PKR 56,000 | $0.10 |
|
||||||
|
| Scale | 5,000 | $300.00 | ≈ PKR 83,000 | $0.06 |
|
||||||
|
| Enterprise | 20,000 | $1,200.00 | ≈ PKR 334,000 | $0.06 |
|
||||||
|
|
||||||
|
### Credit Package Purchase Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CREDIT PACKAGE PURCHASE FLOW │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
User selects package
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────┐
|
||||||
|
│ Create Invoice │
|
||||||
|
│ type='credit_ │
|
||||||
|
│ package' │
|
||||||
|
└─────────┬─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
|
||||||
|
│ Stripe/PayPal? │────▶│ Auto-process via │────▶│ Webhook confirms │
|
||||||
|
│ │ │ payment gateway │ │ payment success │
|
||||||
|
└─────────┬─────────┘ └───────────────────┘ └─────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
│ Bank Transfer? │
|
||||||
|
▼ │
|
||||||
|
┌───────────────────┐ ┌───────────────────┐ │
|
||||||
|
│ Payment created │────▶│ Admin reviews & │ │
|
||||||
|
│ status='pending_ │ │ approves payment │ │
|
||||||
|
│ approval' │ └─────────┬─────────┘ │
|
||||||
|
└───────────────────┘ │ │
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────────────────────────┐
|
||||||
|
│ PAYMENT APPROVED │
|
||||||
|
└─────────────────┬─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────┐
|
||||||
|
│ CreditService.add_bonus_credits() │
|
||||||
|
│ • Adds to account.bonus_credits │
|
||||||
|
│ • Creates CreditTransaction │
|
||||||
|
│ • NEVER expires │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credit Package Invoice Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Invoice Created ──▶ 48 hours ──▶ Reminder Sent ──▶ 48 hours ──▶ Invoice Voided
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
status='pending' status='pending' status='void'
|
||||||
|
(reminder sent) (auto-cancelled)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Invoice System
|
||||||
|
|
||||||
|
### Invoice Types
|
||||||
|
|
||||||
|
| Type | Description | Auto-Pay | Manual Pay |
|
||||||
|
|------|-------------|----------|------------|
|
||||||
|
| `subscription` | Monthly plan payment | Stripe/PayPal | Bank Transfer |
|
||||||
|
| `credit_package` | One-time credit purchase | Stripe/PayPal | Bank Transfer |
|
||||||
|
| `addon` | Additional features | Stripe/PayPal | Bank Transfer |
|
||||||
|
| `custom` | Manual/admin-created | ❌ | ✅ |
|
||||||
|
|
||||||
|
### Invoice Status Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
|
│ DRAFT │────▶│ SENT │────▶│ PENDING │────▶│ PAID │
|
||||||
|
└─────────┘ └─────────┘ └────┬────┘ └─────────┘
|
||||||
|
│
|
||||||
|
┌────────┴────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐
|
||||||
|
│ OVERDUE │ │ FAILED │
|
||||||
|
└────┬─────┘ └──────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────┐ ┌──────────┐
|
||||||
|
│ VOID │ │CANCELLED │
|
||||||
|
└──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invoice Status Reference
|
||||||
|
|
||||||
|
| Status | Description | User Action |
|
||||||
|
|--------|-------------|-------------|
|
||||||
|
| `draft` | Being created | - |
|
||||||
|
| `sent` | Delivered to user | Pay Now |
|
||||||
|
| `pending` | Awaiting payment | Pay Now |
|
||||||
|
| `overdue` | Past due date | Pay Now (urgent) |
|
||||||
|
| `paid` | Payment received | Download PDF |
|
||||||
|
| `failed` | Payment failed | Retry/Pay Now |
|
||||||
|
| `void` | Cancelled by system | - |
|
||||||
|
| `cancelled` | Cancelled by admin | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Renewal Workflow
|
||||||
|
|
||||||
|
### Renewal Timeline by Payment Method
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ RENEWAL TIMELINE - STRIPE/PAYPAL (AUTO-PAY) │
|
||||||
|
│ Industry Standard: No Advance Notice │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Day 0 (Renewal) Day +1 Day +7 │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ │
|
||||||
|
│ │Auto-Pay │ │ If Failed: │ │ Expired │ │
|
||||||
|
│ │ Attempt │ │ Retry + │ │ (after │ │
|
||||||
|
│ └────┬─────┘ │ Email Sent │ │ retries) │ │
|
||||||
|
│ │ └────────────┘ └──────────┘ │
|
||||||
|
│ ┌────┴────┐ │
|
||||||
|
│ │ SUCCESS │──▶ Receipt email + Credits reset to plan amount │
|
||||||
|
│ └────┬────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────┴────┐ │
|
||||||
|
│ │ FAILURE │──▶ Payment failed email, Stripe retries 4x over 7 days │
|
||||||
|
│ └─────────┘ │
|
||||||
|
│ │
|
||||||
|
│ EMAIL SCHEDULE (Industry Standard - Netflix, Spotify, Adobe): │
|
||||||
|
│ • Payment Success: Receipt immediately │
|
||||||
|
│ • Payment Failed: Notification after each retry attempt │
|
||||||
|
│ • Final Warning: 1 day before account suspension │
|
||||||
|
│ • Account Suspended: When grace period ends │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ RENEWAL TIMELINE - BANK TRANSFER │
|
||||||
|
│ Simplified 3-Email Flow │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Day -3 Day 0 Day +1 Day +7 │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ ▼ │
|
||||||
|
│ ┌────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │Invoice │ │Reminder │ │ Urgent │ │ Expired │ │
|
||||||
|
│ │Created │ │ Email │ │Reminder │ │ │ │
|
||||||
|
│ │+Email │ │(if not │ │+Credits │ │ │ │
|
||||||
|
│ │ │ │ paid) │ │ Reset │ │ │ │
|
||||||
|
│ └────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Timeline: │
|
||||||
|
│ • Day -3: Invoice created + Email sent with payment instructions │
|
||||||
|
│ • Day 0: Reminder email (renewal day, if still unpaid) │
|
||||||
|
│ • Day +1: Urgent reminder + credits reset to 0 (if unpaid) │
|
||||||
|
│ • Day +7: Subscription expired │
|
||||||
|
│ │
|
||||||
|
│ EMAILS: │
|
||||||
|
│ 1. Invoice Email (Day -3): Invoice attached, bank details, Pay Now link │
|
||||||
|
│ 2. Renewal Reminder (Day 0): "Your subscription renews today" │
|
||||||
|
│ 3. Urgent Reminder (Day +1): "Payment overdue - action required" │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renewal Credit Behavior
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CREDIT RESET ON RENEWAL │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ SCENARIO 1: Payment Before Renewal │
|
||||||
|
│ ───────────────────────────────────── │
|
||||||
|
│ • Credits NOT reset early │
|
||||||
|
│ • On payment confirmation: │
|
||||||
|
│ - plan credits → reset to plan.included_credits │
|
||||||
|
│ - bonus_credits → UNCHANGED (never affected) │
|
||||||
|
│ │
|
||||||
|
│ SCENARIO 2: Payment After Renewal (within 24h) │
|
||||||
|
│ ────────────────────────────────────────────── │
|
||||||
|
│ • Credits stay unchanged for 24 hours │
|
||||||
|
│ • On payment confirmation: │
|
||||||
|
│ - plan credits → reset to plan.included_credits │
|
||||||
|
│ - bonus_credits → UNCHANGED │
|
||||||
|
│ │
|
||||||
|
│ SCENARIO 3: No Payment After 24 Hours │
|
||||||
|
│ ───────────────────────────────────── │
|
||||||
|
│ • plan credits → reset to 0 (task: reset_unpaid_renewal_credits) │
|
||||||
|
│ • bonus_credits → UNCHANGED (user can still use these) │
|
||||||
|
│ • Warning email sent │
|
||||||
|
│ │
|
||||||
|
│ SCENARIO 4: Payment After Credit Reset │
|
||||||
|
│ ────────────────────────────────────── │
|
||||||
|
│ • On payment confirmation: │
|
||||||
|
│ - plan credits → reset to plan.included_credits (restored) │
|
||||||
|
│ - bonus_credits → UNCHANGED │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Celery Tasks Schedule
|
||||||
|
|
||||||
|
| Task | Schedule | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `create_bank_transfer_invoices` | Daily 09:00 | Create invoices 3 days before renewal (bank transfer only) |
|
||||||
|
| `process_subscription_renewals` | Daily 00:05 | Process auto-pay renewals (Stripe/PayPal) |
|
||||||
|
| `send_renewal_day_reminders` | Daily 10:00 | Send Day 0 reminder for bank transfer (if unpaid) |
|
||||||
|
| `send_day_after_reminders` | Daily 09:15 | Send Day +1 urgent reminders + reset credits |
|
||||||
|
| `check_expired_renewals` | Daily 00:15 | Mark subscriptions expired after 7-day grace period |
|
||||||
|
| `send_credit_invoice_expiry_reminders` | Daily 09:30 | Remind about expiring credit package invoices |
|
||||||
|
| `void_expired_credit_invoices` | Daily 00:45 | Auto-void credit invoices after 48h |
|
||||||
|
|
||||||
|
### Email Schedule Summary
|
||||||
|
|
||||||
|
**Stripe/PayPal (Auto-Pay) - Industry Standard:**
|
||||||
|
| Event | Email |
|
||||||
|
|-------|-------|
|
||||||
|
| Payment Success | ✅ Receipt/Confirmation |
|
||||||
|
| Payment Failed | ⚠️ Retry notification (per attempt) |
|
||||||
|
| Final Warning | 🚨 1 day before suspension |
|
||||||
|
| Account Suspended | ❌ Subscription expired |
|
||||||
|
|
||||||
|
**Bank Transfer (Manual Pay):**
|
||||||
|
| Day | Email |
|
||||||
|
|-----|-------|
|
||||||
|
| Day -3 | 📧 Invoice created + payment instructions |
|
||||||
|
| Day 0 | ⏰ Renewal day reminder (if unpaid) |
|
||||||
|
| Day +1 | 🚨 Urgent reminder + credits reset warning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Admin Operations
|
||||||
|
|
||||||
|
### Webhook Events (Payment Logs)
|
||||||
|
|
||||||
|
**Location:** Admin → Billing → Webhook Events
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Event ID | Unique ID from Stripe/PayPal |
|
||||||
|
| Provider | STRIPE or PAYPAL badge |
|
||||||
|
| Event Type | e.g., `checkout.session.completed`, `PAYMENT.CAPTURE.COMPLETED` |
|
||||||
|
| Status | Processed ✓, Failed ✗, Pending ⏳ |
|
||||||
|
| Process Time | Actual processing duration |
|
||||||
|
| Created | When webhook received |
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
- Mark as processed
|
||||||
|
- Retry processing
|
||||||
|
- View full payload (JSON)
|
||||||
|
|
||||||
|
### Payment Approval (Bank Transfer)
|
||||||
|
|
||||||
|
**Location:** Admin → Billing → Payments
|
||||||
|
|
||||||
|
**Approval Flow:**
|
||||||
|
1. Filter by `status = pending_approval`
|
||||||
|
2. Review `manual_reference` and `manual_notes`
|
||||||
|
3. Check proof of payment upload
|
||||||
|
4. Change status to `succeeded`
|
||||||
|
5. System automatically:
|
||||||
|
- Updates invoice to `paid`
|
||||||
|
- Activates account (if subscription)
|
||||||
|
- Adds credits (plan or bonus based on invoice type)
|
||||||
|
|
||||||
|
### Manual Credit Adjustment
|
||||||
|
|
||||||
|
**Location:** Admin → Billing → Credit Transactions
|
||||||
|
|
||||||
|
**To add credits manually:**
|
||||||
|
1. Go to Account admin
|
||||||
|
2. Edit the account
|
||||||
|
3. Modify `credits` (plan) or `bonus_credits` (purchased)
|
||||||
|
4. Save with note in admin_notes
|
||||||
|
|
||||||
|
**OR use shell:**
|
||||||
|
```python
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
|
||||||
|
# Add plan credits
|
||||||
|
CreditService.add_credits(
|
||||||
|
account=account,
|
||||||
|
amount=500,
|
||||||
|
transaction_type='manual',
|
||||||
|
description='Manual adjustment - support ticket #123'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add bonus credits
|
||||||
|
CreditService.add_bonus_credits(
|
||||||
|
account=account,
|
||||||
|
amount=500,
|
||||||
|
description='Promotional bonus - January 2026'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API Reference
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/v1/billing/credits/` | GET | Get credit balance |
|
||||||
|
| `/api/v1/billing/credits/usage/` | GET | Get usage statistics |
|
||||||
|
| `/api/v1/billing/invoices/` | GET | List invoices |
|
||||||
|
| `/api/v1/billing/invoices/{id}/` | GET | Invoice detail |
|
||||||
|
| `/api/v1/billing/invoices/{id}/pdf/` | GET | Download invoice PDF |
|
||||||
|
| `/api/v1/billing/payments/` | GET | List payments |
|
||||||
|
| `/api/v1/billing/plans/` | GET | List available plans |
|
||||||
|
| `/api/v1/billing/subscriptions/` | GET | List subscriptions |
|
||||||
|
| `/api/v1/billing/credit-packages/` | GET | List credit packages |
|
||||||
|
| `/api/v1/billing/purchase/credits/` | POST | Purchase credit package |
|
||||||
|
| `/api/v1/billing/subscribe/` | POST | Subscribe to plan |
|
||||||
|
| `/api/v1/webhooks/stripe/` | POST | Stripe webhook endpoint |
|
||||||
|
| `/api/v1/webhooks/paypal/` | POST | PayPal webhook endpoint |
|
||||||
|
|
||||||
|
### Credit Balance Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"credits": 3500,
|
||||||
|
"bonus_credits": 2000,
|
||||||
|
"total_credits": 5500,
|
||||||
|
"credits_used_this_month": 1500,
|
||||||
|
"plan_credits_per_month": 5000,
|
||||||
|
"subscription_plan": "Scale",
|
||||||
|
"period_end": "2026-02-12T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Database Schema
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ accounts │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ id │ PK │
|
||||||
|
│ name │ Account name │
|
||||||
|
│ status │ pending, active, expired, etc. │
|
||||||
|
│ credits │ Plan credits (resets on renewal) │
|
||||||
|
│ bonus_credits │ Purchased credits (never expire) │
|
||||||
|
│ plan_id │ FK → plans │
|
||||||
|
│ billing_email │ Email for invoices │
|
||||||
|
│ billing_country │ Country code (PK, US, etc.) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ subscriptions │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ id │ PK │
|
||||||
|
│ account_id │ FK → accounts │
|
||||||
|
│ plan_id │ FK → plans │
|
||||||
|
│ status │ pending, active, pending_renewal, expired, etc. │
|
||||||
|
│ current_period_start │ Start of current billing period │
|
||||||
|
│ current_period_end │ End of current billing period (renewal date) │
|
||||||
|
│ metadata │ JSON (stripe_subscription_id, paypal_sub_id) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ invoices │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ id │ PK │
|
||||||
|
│ invoice_number │ Unique invoice number (INV-2026-00001) │
|
||||||
|
│ account_id │ FK → accounts │
|
||||||
|
│ subscription_id │ FK → subscriptions (nullable) │
|
||||||
|
│ invoice_type │ subscription, credit_package, addon, custom │
|
||||||
|
│ status │ draft, sent, pending, paid, overdue, void, etc. │
|
||||||
|
│ total_amount │ Total amount │
|
||||||
|
│ currency │ USD, PKR │
|
||||||
|
│ due_date │ Payment due date │
|
||||||
|
│ paid_at │ When payment received │
|
||||||
|
│ metadata │ JSON (credit_package_id, etc.) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ payments │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ id │ PK │
|
||||||
|
│ invoice_id │ FK → invoices │
|
||||||
|
│ account_id │ FK → accounts │
|
||||||
|
│ amount │ Payment amount │
|
||||||
|
│ currency │ USD, PKR │
|
||||||
|
│ payment_method │ stripe, paypal, bank_transfer │
|
||||||
|
│ status │ pending_approval, processing, succeeded, failed│
|
||||||
|
│ stripe_payment_intent_id│ Stripe reference │
|
||||||
|
│ paypal_order_id │ PayPal reference │
|
||||||
|
│ manual_reference │ Bank transfer reference │
|
||||||
|
│ approved_by_id │ FK → users (admin who approved) │
|
||||||
|
│ approved_at │ When approved │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ credit_transactions │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ id │ PK │
|
||||||
|
│ account_id │ FK → accounts │
|
||||||
|
│ transaction_type │ subscription, purchase, usage, refund, manual, etc. │
|
||||||
|
│ amount │ Credits added (+) or deducted (-) │
|
||||||
|
│ balance_after │ Balance after this transaction │
|
||||||
|
│ description │ Human-readable description │
|
||||||
|
│ metadata │ JSON (invoice_id, payment_id, etc.) │
|
||||||
|
│ created_at │ Timestamp │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ webhook_events │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ id │ PK │
|
||||||
|
│ event_id │ Unique event ID from provider │
|
||||||
|
│ provider │ stripe, paypal │
|
||||||
|
│ event_type │ checkout.session.completed, PAYMENT.CAPTURE.COMPLETED │
|
||||||
|
│ payload │ JSON - full webhook payload │
|
||||||
|
│ processed │ Boolean - successfully processed? │
|
||||||
|
│ processed_at │ When processed │
|
||||||
|
│ error_message│ Error if processing failed │
|
||||||
|
│ retry_count │ Number of retry attempts │
|
||||||
|
│ created_at │ When received │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference Card
|
||||||
|
|
||||||
|
### Credit Consumption Priority
|
||||||
|
1. **Plan credits** used first
|
||||||
|
2. **Bonus credits** used only when plan credits = 0
|
||||||
|
|
||||||
|
### What Happens on Renewal
|
||||||
|
|
||||||
|
| Event | Plan Credits | Bonus Credits |
|
||||||
|
|-------|--------------|---------------|
|
||||||
|
| Payment success | Reset to plan amount | No change |
|
||||||
|
| No payment (24h) | Reset to 0 | No change |
|
||||||
|
| Late payment | Reset to plan amount | No change |
|
||||||
|
|
||||||
|
### Payment Method Availability
|
||||||
|
|
||||||
|
| Country | Stripe | PayPal | Bank Transfer |
|
||||||
|
|---------|--------|--------|---------------|
|
||||||
|
| Pakistan (PK) | ✅ | ❌ | ✅ |
|
||||||
|
| Others | ✅ | ✅ | ❌ |
|
||||||
|
|
||||||
|
### Invoice Expiry
|
||||||
|
|
||||||
|
| Invoice Type | Expiry |
|
||||||
|
|--------------|--------|
|
||||||
|
| Subscription | 7 days (grace period) |
|
||||||
|
| Credit Package | 48 hours |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document generated from current codebase implementation as of January 20, 2026*
|
||||||
196
docs/90-REFERENCE/BILLING-SYSTEM-MASTER.md
Normal file
196
docs/90-REFERENCE/BILLING-SYSTEM-MASTER.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# IGNY8 Billing System Master Document
|
||||||
|
|
||||||
|
**Last Updated:** 2026-01-20
|
||||||
|
|
||||||
|
This document is the authoritative reference for the billing system implementation, including models, invoice lifecycle, payment flows, credit reset logic, and notification timing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Core Principles
|
||||||
|
|
||||||
|
- **No hardcoded products**: Plans, credit packages, and future add-ons are data-driven.
|
||||||
|
- **Explicit invoice type**: `subscription`, `credit_package`, `addon`, `custom`.
|
||||||
|
- **Correct crediting**:
|
||||||
|
- Subscription credits reset to **0** at cycle end.
|
||||||
|
- Subscription credits reset to **full plan amount** on renewal/activation.
|
||||||
|
- Credit package credits add to balance.
|
||||||
|
- **Lifecycle governance**: Credit invoices expire automatically and can be cancelled by users.
|
||||||
|
- **Auditability**: Every credit change is recorded in `CreditTransaction`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Data Model Overview
|
||||||
|
|
||||||
|
### 2.1 Invoice
|
||||||
|
| Field | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `invoice_type` | `subscription`, `credit_package`, `addon`, `custom` |
|
||||||
|
| `status` | `draft`, `pending`, `paid`, `void`, `uncollectible` |
|
||||||
|
| `expires_at` | Expiry for credit invoices |
|
||||||
|
| `void_reason` | Cancellation/expiration reason |
|
||||||
|
| `line_items` | Itemized charges (product‑driven) |
|
||||||
|
| `metadata` | Compatibility + gateway context |
|
||||||
|
|
||||||
|
### 2.2 Payment
|
||||||
|
| Field | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `status` | `pending_approval`, `succeeded`, `failed`, `refunded` |
|
||||||
|
| `payment_method` | `stripe`, `paypal`, `bank_transfer`, `local_wallet`, `manual` |
|
||||||
|
| `invoice` | FK to invoice |
|
||||||
|
| `metadata` | Gateway and fulfillment details |
|
||||||
|
|
||||||
|
### 2.3 Credits
|
||||||
|
| Field | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `Account.credits` | Current balance |
|
||||||
|
| `CreditTransaction` | Immutable ledger of changes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Invoice Types and Fulfillment Rules
|
||||||
|
|
||||||
|
| Invoice Type | Fulfillment | Account Status Change |
|
||||||
|
|---|---|---|
|
||||||
|
| `subscription` | Reset credits to plan amount | Activate account + subscription |
|
||||||
|
| `credit_package` | Add package credits | No status change |
|
||||||
|
| `addon` | Provision add-on entitlement | No status change |
|
||||||
|
| `custom` | No credits unless specified | No status change |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Flowcharts
|
||||||
|
|
||||||
|
### 4.1 Subscription Purchase (Stripe / PayPal / Bank Transfer)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[User selects plan] --> B[Create subscription invoice]
|
||||||
|
B --> C{Payment Method}
|
||||||
|
C -->|Stripe/PayPal| D[Gateway checkout]
|
||||||
|
D --> E[Webhook payment succeeded]
|
||||||
|
E --> F[Invoice paid]
|
||||||
|
F --> G[Reset credits to full plan amount]
|
||||||
|
G --> H[Account + subscription active]
|
||||||
|
|
||||||
|
C -->|Bank Transfer| I[User submits confirmation]
|
||||||
|
I --> J[Payment pending_approval]
|
||||||
|
J --> K[Admin approves]
|
||||||
|
K --> F
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Subscription Renewal (Automatic + Manual)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Billing cycle end] --> B[Reset credits to 0]
|
||||||
|
B --> C[Create renewal invoice]
|
||||||
|
C --> D{Auto method available?}
|
||||||
|
D -->|Yes| E[Gateway charges]
|
||||||
|
E --> F[Webhook payment succeeded]
|
||||||
|
F --> G[Reset credits to full plan amount]
|
||||||
|
G --> H[Subscription active]
|
||||||
|
|
||||||
|
D -->|No| I[Manual payment required]
|
||||||
|
I --> J[Invoice emailed]
|
||||||
|
J --> K[Admin approves]
|
||||||
|
K --> G
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Credit Package Purchase (All Methods)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[User selects credit package] --> B[Create credit invoice + expires_at]
|
||||||
|
B --> C{Payment Method}
|
||||||
|
C -->|Stripe/PayPal| D[Gateway checkout]
|
||||||
|
D --> E[Webhook payment succeeded]
|
||||||
|
E --> F[Add package credits]
|
||||||
|
|
||||||
|
C -->|Bank Transfer| G[User submits confirmation]
|
||||||
|
G --> H[Payment pending_approval]
|
||||||
|
H --> I[Admin approves]
|
||||||
|
I --> F
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Credit Invoice Expiry
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Scheduler checks pending credit invoices] --> B{expires_at <= now?}
|
||||||
|
B -->|Yes| C[Void invoice + notify user]
|
||||||
|
B -->|No| D[No action]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Credit Invoice Cancellation (User)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[User clicks cancel] --> B{Pending credit invoice?}
|
||||||
|
B -->|Yes| C[Set status=void, void_reason=user_cancelled]
|
||||||
|
C --> D[Notify user]
|
||||||
|
B -->|No| E[Reject request]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Notification Matrix (Required Emails)
|
||||||
|
|
||||||
|
| Event | Email | Timing |
|
||||||
|
|---|---|---|
|
||||||
|
| Manual payment submitted | Payment confirmation | Immediate |
|
||||||
|
| Manual payment approved | Payment approved | Immediate |
|
||||||
|
| Manual payment rejected | Payment rejected | Immediate |
|
||||||
|
| Renewal notice | Subscription renewal notice | N days before end |
|
||||||
|
| Renewal invoice | Invoice email | At renewal creation |
|
||||||
|
| Renewal reminder | Invoice reminder | Every 3 days until paid |
|
||||||
|
| Credit invoice expiring | Credit invoice expiring | 24h before expires_at |
|
||||||
|
| Credit invoice expired | Credit invoice expired | At void |
|
||||||
|
| Credit invoice cancelled | Credit invoice cancelled | Immediate |
|
||||||
|
| Payment failed | Payment failed | Immediate |
|
||||||
|
| Low credits | Low credits warning | Threshold crossing |
|
||||||
|
| Subscription expired | Subscription expired | When grace period ends |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Scheduled Tasks
|
||||||
|
|
||||||
|
| Task | Purpose | Schedule |
|
||||||
|
|---|---|---|
|
||||||
|
| send_renewal_notices | Renewal reminders | Daily 09:00 |
|
||||||
|
| process_subscription_renewals | Create renewal invoices | Daily 00:05 |
|
||||||
|
| send_invoice_reminders | Remind pending renewals | Daily 10:00 |
|
||||||
|
| check_expired_renewals | Expire grace period | Daily 00:15 |
|
||||||
|
| send_credit_invoice_expiry_reminders | Credit invoice expiry reminder | Daily 09:30 |
|
||||||
|
| void_expired_credit_invoices | Auto-void expired credit invoices | Daily 00:45 |
|
||||||
|
| replenish_monthly_credits | Monthly credit reset (legacy) | 1st day monthly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Key Implementation Rules
|
||||||
|
|
||||||
|
1. **Invoice type drives fulfillment** (no plan/package hardcoding).
|
||||||
|
2. **Subscription credits**: reset to **0** at cycle end, then set to **full amount** on renewal.
|
||||||
|
3. **Credit packages**: add package credits only.
|
||||||
|
4. **Credit invoices**: expire automatically and are cancellable by users.
|
||||||
|
5. **Account status** only changes on **subscription invoices**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Operational Verification Checklist
|
||||||
|
|
||||||
|
- Stripe/PayPal subscription purchase activates account and resets credits.
|
||||||
|
- Bank transfer subscription approval resets credits (not adds).
|
||||||
|
- Credit package approval adds package credits (not plan credits).
|
||||||
|
- Credit invoices do not block subscription state.
|
||||||
|
- Credit invoice expiry and cancellation work with emails.
|
||||||
|
- Renewal resets credits to 0 at cycle end and to full on payment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Extensibility (Future Add‑ons)
|
||||||
|
|
||||||
|
- Add new `Product` types without code changes to invoice fulfillment.
|
||||||
|
- Use `invoice_type='addon'` with line items to provision entitlements.
|
||||||
|
- No plan/package hardcoding required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) References
|
||||||
|
|
||||||
|
- Audit: [docs/audits/BILLING-SYSTEM-AUDIT-20260120.md](docs/audits/BILLING-SYSTEM-AUDIT-20260120.md)
|
||||||
|
- Refactor Plan: [docs/audits/BILLING-SYSTEM-AUDIT-AND-REFACTOR-PLAN-20260120.md](docs/audits/BILLING-SYSTEM-AUDIT-AND-REFACTOR-PLAN-20260120.md)
|
||||||
@@ -130,13 +130,29 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{balance && (
|
{balance && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Current Balance</h3>
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Plan Credits</h3>
|
||||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
{(balance?.credits ?? 0).toLocaleString()}
|
{(balance?.credits ?? 0).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Available credits</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Reset on renewal</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6 border-l-4 border-l-success-500">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Bonus Credits</h3>
|
||||||
|
<div className="text-3xl font-bold text-success-600 dark:text-success-400">
|
||||||
|
{((balance as any)?.bonus_credits ?? 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Never expire</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Total Available</h3>
|
||||||
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{((balance as any)?.total_credits ?? (balance?.credits ?? 0)).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Plan + Bonus</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -145,19 +161,30 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU
|
|||||||
{(balance?.plan_credits_per_month ?? 0).toLocaleString()}
|
{(balance?.plan_credits_per_month ?? 0).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{(balance as any)?.subscription_plan || 'No plan'}
|
{(balance as any)?.subscription_plan || 'From plan'}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="p-6">
|
{/* Info box about credit consumption order */}
|
||||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Status</h3>
|
{balance && ((balance as any)?.bonus_credits ?? 0) > 0 && (
|
||||||
<div className="mt-2">
|
<Card className="p-4 bg-info-50 dark:bg-info-900/20 border-info-200 dark:border-info-800">
|
||||||
<Badge variant="light" className="text-lg">
|
<div className="flex items-start space-x-3">
|
||||||
{(balance as any)?.subscription_status || 'No subscription'}
|
<div className="text-info-600 dark:text-info-400">
|
||||||
</Badge>
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-info-800 dark:text-info-200">Credit Consumption Order</h4>
|
||||||
|
<p className="text-sm text-info-700 dark:text-info-300 mt-1">
|
||||||
|
Plan credits are used first. Bonus credits are only consumed after plan credits are exhausted.
|
||||||
|
Bonus credits never expire and are not affected by plan renewals.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{usageLimits && (
|
{usageLimits && (
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
import { API_BASE_URL } from '../../services/api';
|
import { API_BASE_URL } from '../../services/api';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { subscribeToPlan, getAvailablePaymentMethods } from '../../services/billing.api';
|
import { subscribeToPlan, getAvailablePaymentMethods, purchaseCredits } from '../../services/billing.api';
|
||||||
|
|
||||||
interface BankDetails {
|
interface BankDetails {
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
@@ -38,6 +38,8 @@ interface BankDetails {
|
|||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: number;
|
id: number;
|
||||||
invoice_number: string;
|
invoice_number: string;
|
||||||
|
invoice_type?: 'subscription' | 'credit_package' | 'addon' | 'custom';
|
||||||
|
credit_package_id?: string | number | null;
|
||||||
total?: string;
|
total?: string;
|
||||||
total_amount?: string;
|
total_amount?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
@@ -126,6 +128,8 @@ export default function PayInvoiceModal({
|
|||||||
const currency = invoice.currency?.toUpperCase() || 'USD';
|
const currency = invoice.currency?.toUpperCase() || 'USD';
|
||||||
const planId = invoice.subscription?.plan?.id;
|
const planId = invoice.subscription?.plan?.id;
|
||||||
const planSlug = invoice.subscription?.plan?.slug;
|
const planSlug = invoice.subscription?.plan?.slug;
|
||||||
|
const isCreditInvoice = invoice.invoice_type === 'credit_package';
|
||||||
|
const creditPackageId = invoice.credit_package_id ? String(invoice.credit_package_id) : null;
|
||||||
|
|
||||||
// Check if user's default method is selected (for showing badge)
|
// Check if user's default method is selected (for showing badge)
|
||||||
const isDefaultMethod = (option: PaymentOption): boolean => {
|
const isDefaultMethod = (option: PaymentOption): boolean => {
|
||||||
@@ -181,6 +185,29 @@ export default function PayInvoiceModal({
|
|||||||
}, [isOpen, isPakistan, selectedOption, userCountry, bankDetails]);
|
}, [isOpen, isPakistan, selectedOption, userCountry, bankDetails]);
|
||||||
|
|
||||||
const handleStripePayment = async () => {
|
const handleStripePayment = async () => {
|
||||||
|
if (isCreditInvoice) {
|
||||||
|
if (!creditPackageId) {
|
||||||
|
setError('Unable to process card payment. Credit package not found on invoice. Please contact support.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const result = await purchaseCredits(creditPackageId, 'stripe', {
|
||||||
|
return_url: `${window.location.origin}/account/usage?purchase=success`,
|
||||||
|
cancel_url: `${window.location.origin}/account/usage?purchase=canceled`,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = result.redirect_url;
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to initiate card payment');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Use plan slug if available, otherwise fall back to id
|
// Use plan slug if available, otherwise fall back to id
|
||||||
const planIdentifier = planSlug || (planId ? String(planId) : null);
|
const planIdentifier = planSlug || (planId ? String(planId) : null);
|
||||||
|
|
||||||
@@ -208,6 +235,29 @@ export default function PayInvoiceModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePayPalPayment = async () => {
|
const handlePayPalPayment = async () => {
|
||||||
|
if (isCreditInvoice) {
|
||||||
|
if (!creditPackageId) {
|
||||||
|
setError('Unable to process PayPal payment. Credit package not found on invoice. Please contact support.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const result = await purchaseCredits(creditPackageId, 'paypal', {
|
||||||
|
return_url: `${window.location.origin}/account/usage?purchase=success`,
|
||||||
|
cancel_url: `${window.location.origin}/account/usage?purchase=canceled`,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = result.redirect_url;
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to initiate PayPal payment');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Use plan slug if available, otherwise fall back to id
|
// Use plan slug if available, otherwise fall back to id
|
||||||
const planIdentifier = planSlug || (planId ? String(planId) : null);
|
const planIdentifier = planSlug || (planId ? String(planId) : null);
|
||||||
|
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ export default function SiteDashboard() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<CreditAvailabilityWidget
|
<CreditAvailabilityWidget
|
||||||
availableCredits={balance?.credits_remaining ?? 0}
|
availableCredits={(balance as any)?.total_credits ?? balance?.credits_remaining ?? 0}
|
||||||
totalCredits={balance?.plan_credits_per_month ?? 0}
|
totalCredits={balance?.plan_credits_per_month ?? 0}
|
||||||
usedCredits={balance?.credits_used_this_month ?? 0}
|
usedCredits={balance?.credits_used_this_month ?? 0}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -921,16 +921,26 @@ export default function PlansAndBillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
|
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-sm text-brand-700 dark:text-brand-300 mb-1">
|
<div className="flex items-center gap-2 text-sm text-brand-700 dark:text-brand-300 mb-1">
|
||||||
<ZapIcon className="w-4 h-4 text-brand-600" />
|
<ZapIcon className="w-4 h-4 text-brand-600" />
|
||||||
Credits
|
Plan Credits
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-brand-900 dark:text-white">
|
<div className="text-2xl font-bold text-brand-900 dark:text-white">
|
||||||
{creditBalance?.credits?.toLocaleString() || 0}
|
{creditBalance?.credits?.toLocaleString() || 0}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-brand-600 dark:text-brand-400 mt-1">Available now</div>
|
<div className="text-xs text-brand-600 dark:text-brand-400 mt-1">Reset on renewal</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm border-l-4 border-l-success-500">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-success-700 dark:text-success-300 mb-1">
|
||||||
|
<ZapIcon className="w-4 h-4 text-success-600" />
|
||||||
|
Bonus Credits
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
||||||
|
{((creditBalance as any)?.bonus_credits || 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-success-600 dark:text-success-400 mt-1">Never expire</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
|
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-sm text-purple-700 dark:text-purple-300 mb-1">
|
<div className="flex items-center gap-2 text-sm text-purple-700 dark:text-purple-300 mb-1">
|
||||||
@@ -972,6 +982,21 @@ export default function PlansAndBillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Total Credits Info Box */}
|
||||||
|
{((creditBalance as any)?.bonus_credits || 0) > 0 && (
|
||||||
|
<div className="mt-4 p-3 bg-white/60 dark:bg-gray-800/60 rounded-lg border border-success-200 dark:border-success-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Available:</span>
|
||||||
|
<span className="text-lg font-bold text-brand-700 dark:text-brand-400">
|
||||||
|
{((creditBalance?.credits || 0) + ((creditBalance as any)?.bonus_credits || 0)).toLocaleString()} credits
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">Plan + Bonus</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Credit Usage Bar */}
|
{/* Credit Usage Bar */}
|
||||||
<div className="mt-6 pt-6 border-t border-brand-200/50 dark:border-brand-700/30">
|
<div className="mt-6 pt-6 border-t border-brand-200/50 dark:border-brand-700/30">
|
||||||
<div className="flex justify-between text-sm mb-2">
|
<div className="flex justify-between text-sm mb-2">
|
||||||
@@ -1319,17 +1344,22 @@ export default function PlansAndBillingPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-center">
|
<td className="px-6 py-3 text-center">
|
||||||
<Badge variant="soft" tone={invoice.status === 'paid' ? 'success' : 'warning'}>
|
<Badge variant="soft" tone={
|
||||||
|
invoice.status === 'paid' ? 'success' :
|
||||||
|
invoice.status === 'failed' ? 'error' :
|
||||||
|
invoice.status === 'overdue' ? 'error' :
|
||||||
|
'warning'
|
||||||
|
}>
|
||||||
{invoice.status}
|
{invoice.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-end">
|
<td className="px-6 py-3 text-end">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{invoice.status === 'pending' && (
|
{['pending', 'overdue', 'failed', 'sent'].includes(invoice.status) && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
tone="brand"
|
tone={invoice.status === 'overdue' || invoice.status === 'failed' ? 'error' : 'brand'}
|
||||||
startIcon={<DollarLineIcon className="w-4 h-4" />}
|
startIcon={<DollarLineIcon className="w-4 h-4" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedInvoice(invoice);
|
setSelectedInvoice(invoice);
|
||||||
|
|||||||
@@ -469,27 +469,49 @@ export default function UsageDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-6">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-4xl font-bold text-brand-700 dark:text-brand-400 mb-1">
|
<div className="text-3xl font-bold text-brand-700 dark:text-brand-400 mb-1">
|
||||||
{creditBalance?.credits.toLocaleString() || 0}
|
{creditBalance?.credits.toLocaleString() || 0}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-brand-600 dark:text-brand-300">Available Now</div>
|
<div className="text-sm text-brand-600 dark:text-brand-300">Plan Credits</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-4xl font-bold text-purple-700 dark:text-purple-400 mb-1">
|
<div className="text-3xl font-bold text-success-600 dark:text-success-400 mb-1">
|
||||||
|
{((creditBalance as any)?.bonus_credits || 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-success-600 dark:text-success-300">Bonus Credits</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Never expire</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-purple-700 dark:text-purple-400 mb-1">
|
||||||
{creditBalance?.credits_used_this_month.toLocaleString() || 0}
|
{creditBalance?.credits_used_this_month.toLocaleString() || 0}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-purple-600 dark:text-purple-300">Used This Month</div>
|
<div className="text-sm text-purple-600 dark:text-purple-300">Used This Month</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-4xl font-bold text-indigo-800 dark:text-white mb-1">
|
<div className="text-3xl font-bold text-indigo-800 dark:text-white mb-1">
|
||||||
{creditBalance?.plan_credits_per_month.toLocaleString() || 0}
|
{creditBalance?.plan_credits_per_month.toLocaleString() || 0}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-indigo-600 dark:text-indigo-300">Monthly Allowance</div>
|
<div className="text-sm text-indigo-600 dark:text-indigo-300">Monthly Allowance</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Total Available Credits */}
|
||||||
|
{((creditBalance as any)?.bonus_credits || 0) > 0 && (
|
||||||
|
<div className="mt-4 p-3 bg-white/60 dark:bg-gray-800/60 rounded-lg border border-brand-200 dark:border-brand-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Available</span>
|
||||||
|
<span className="text-lg font-bold text-brand-700 dark:text-brand-400">
|
||||||
|
{((creditBalance?.credits || 0) + ((creditBalance as any)?.bonus_credits || 0)).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Plan credits are used first, then bonus credits
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Credit Usage Bar */}
|
{/* Credit Usage Bar */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="flex justify-between text-sm mb-2">
|
<div className="flex justify-between text-sm mb-2">
|
||||||
|
|||||||
@@ -2087,7 +2087,9 @@ export async function fetchGlobalModuleSettings(): Promise<GlobalModuleSettings>
|
|||||||
|
|
||||||
// Billing API functions
|
// Billing API functions
|
||||||
export interface CreditBalance {
|
export interface CreditBalance {
|
||||||
credits: number;
|
credits: number; // Plan credits (reset on renewal)
|
||||||
|
bonus_credits: number; // Purchased credits (never expire)
|
||||||
|
total_credits: number; // Sum of plan + bonus credits
|
||||||
plan_credits_per_month: number;
|
plan_credits_per_month: number;
|
||||||
credits_used_this_month: number;
|
credits_used_this_month: number;
|
||||||
credits_remaining: number;
|
credits_remaining: number;
|
||||||
@@ -2143,6 +2145,8 @@ export async function fetchCreditBalance(): Promise<CreditBalance> {
|
|||||||
// Default if response is invalid
|
// Default if response is invalid
|
||||||
return {
|
return {
|
||||||
credits: 0,
|
credits: 0,
|
||||||
|
bonus_credits: 0,
|
||||||
|
total_credits: 0,
|
||||||
plan_credits_per_month: 0,
|
plan_credits_per_month: 0,
|
||||||
credits_used_this_month: 0,
|
credits_used_this_month: 0,
|
||||||
credits_remaining: 0,
|
credits_remaining: 0,
|
||||||
@@ -2152,6 +2156,8 @@ export async function fetchCreditBalance(): Promise<CreditBalance> {
|
|||||||
// Return default balance on error so UI can still render
|
// Return default balance on error so UI can still render
|
||||||
return {
|
return {
|
||||||
credits: 0,
|
credits: 0,
|
||||||
|
bonus_credits: 0,
|
||||||
|
total_credits: 0,
|
||||||
plan_credits_per_month: 0,
|
plan_credits_per_month: 0,
|
||||||
credits_used_this_month: 0,
|
credits_used_this_month: 0,
|
||||||
credits_remaining: 0,
|
credits_remaining: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user