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)
|
||||
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')
|
||||
payment_method = models.CharField(
|
||||
max_length=30,
|
||||
@@ -143,6 +144,11 @@ class Account(SoftDeletableModel):
|
||||
def __str__(self):
|
||||
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
|
||||
def default_payment_method(self):
|
||||
"""Get default payment method from AccountPaymentMethod table"""
|
||||
|
||||
@@ -41,6 +41,11 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
plan = PlanSerializer(read_only=True)
|
||||
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)
|
||||
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):
|
||||
"""Validate plan_id is provided during creation."""
|
||||
@@ -52,7 +57,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
model = Account
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
|
||||
'credits', 'status', 'payment_method',
|
||||
'credits', 'bonus_credits', 'total_credits', 'status', 'payment_method',
|
||||
'subscription', 'billing_country',
|
||||
'account_timezone', 'timezone_mode', 'timezone_offset',
|
||||
'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.models import (
|
||||
CreditTransaction, Invoice, Payment, CreditPackage,
|
||||
AccountPaymentMethod, PaymentMethodConfig
|
||||
AccountPaymentMethod, PaymentMethodConfig, WebhookEvent
|
||||
)
|
||||
from igny8_core.modules.billing.serializers import PaymentMethodConfigSerializer, PaymentConfirmationSerializer
|
||||
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(
|
||||
f'Bank transfer confirmed for account {account.id}: '
|
||||
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}
|
||||
)
|
||||
|
||||
# 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(
|
||||
f'Payment confirmation submitted: Payment {payment.id}, '
|
||||
f'Invoice {invoice.invoice_number}, Account {request.account.id}, '
|
||||
@@ -442,52 +483,65 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save(update_fields=['status', 'paid_at'])
|
||||
|
||||
# 3. Update Subscription
|
||||
if 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. Update Subscription (subscription invoices only)
|
||||
if invoice_type == 'subscription' and subscription:
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = payment.manual_reference
|
||||
subscription.save(update_fields=['status', 'external_payment_id'])
|
||||
|
||||
# 4. Update Account
|
||||
account.status = 'active'
|
||||
account.save(update_fields=['status'])
|
||||
|
||||
# 5. Add Credits (if subscription has plan)
|
||||
|
||||
# 4. Update Account status (subscription invoices only)
|
||||
if invoice_type == 'subscription' and account.status != 'active':
|
||||
account.status = 'active'
|
||||
account.save(update_fields=['status'])
|
||||
|
||||
# 5. Add/Reset Credits based on invoice type
|
||||
credits_added = 0
|
||||
try:
|
||||
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
||||
credits_added = subscription.plan.included_credits
|
||||
|
||||
# Use CreditService to add credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_added,
|
||||
transaction_type='subscription',
|
||||
description=f'{subscription.plan.name} plan credits - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id,
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': payment.id,
|
||||
'plan_id': subscription.plan.id,
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
elif account and account.plan and account.plan.included_credits > 0:
|
||||
# Fallback: use account plan if subscription not found
|
||||
credits_added = account.plan.included_credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_added,
|
||||
transaction_type='subscription',
|
||||
description=f'{account.plan.name} plan credits - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': payment.id,
|
||||
'plan_id': account.plan.id,
|
||||
'approved_by': request.user.email,
|
||||
'fallback': 'account_plan'
|
||||
}
|
||||
)
|
||||
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
|
||||
# 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,
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise Exception('Credit package ID missing on invoice metadata')
|
||||
elif invoice_type == 'subscription':
|
||||
plan = None
|
||||
if subscription and subscription.plan:
|
||||
plan = subscription.plan
|
||||
elif account and account.plan:
|
||||
plan = account.plan
|
||||
|
||||
if plan and plan.included_credits > 0:
|
||||
credits_added = plan.included_credits
|
||||
CreditService.reset_credits_for_renewal(
|
||||
account=account,
|
||||
new_amount=credits_added,
|
||||
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id if subscription else None,
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': payment.id,
|
||||
'plan_id': plan.id,
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
except Exception as credit_error:
|
||||
# Rollback payment approval if credit addition fails
|
||||
logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True)
|
||||
@@ -495,7 +549,7 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
logger.info(
|
||||
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
|
||||
@@ -513,12 +567,13 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
'account_id': account.id,
|
||||
'account_status': account.status,
|
||||
'subscription_status': subscription.status if subscription else None,
|
||||
'invoice_type': invoice_type,
|
||||
'credits_added': credits_added,
|
||||
'total_credits': account.credits,
|
||||
'approved_by': request.user.email,
|
||||
'approved_at': payment.approved_at.isoformat()
|
||||
},
|
||||
message='Payment approved successfully. Account activated.',
|
||||
message='Payment approved successfully.',
|
||||
request=request
|
||||
)
|
||||
|
||||
@@ -651,6 +706,8 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
'id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'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_amount': str(invoice.total),
|
||||
'subtotal': str(invoice.subtotal),
|
||||
@@ -659,6 +716,8 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
'invoice_date': invoice.invoice_date.isoformat(),
|
||||
'due_date': invoice.due_date.isoformat(),
|
||||
'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,
|
||||
'billing_email': invoice.billing_email,
|
||||
'notes': invoice.notes,
|
||||
@@ -732,6 +791,66 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
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)
|
||||
|
||||
@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):
|
||||
"""ViewSet for user-facing payments"""
|
||||
@@ -942,8 +1061,9 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
# Check for existing pending invoice for this package
|
||||
existing_pending = Invoice.objects.filter(
|
||||
account=account,
|
||||
status='pending',
|
||||
metadata__credit_package_id=package.id
|
||||
status='pending'
|
||||
).filter(
|
||||
Q(invoice_type='credit_package') | Q(metadata__credit_package_id=package.id)
|
||||
).first()
|
||||
|
||||
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 = 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)
|
||||
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
|
||||
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 = [
|
||||
('draft', 'Draft'),
|
||||
('pending', 'Pending'),
|
||||
@@ -345,6 +352,14 @@ class Invoice(AccountBaseModel):
|
||||
]
|
||||
|
||||
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 = models.ForeignKey(
|
||||
@@ -369,6 +384,10 @@ class Invoice(AccountBaseModel):
|
||||
invoice_date = models.DateField(db_index=True)
|
||||
due_date = models.DateField()
|
||||
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 = 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.
|
||||
Actual deduction happens after AI call with real token usage.
|
||||
|
||||
Uses total_credits (plan + bonus) for the check.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
@@ -274,9 +276,11 @@ class CreditService:
|
||||
# Fallback to constants
|
||||
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(
|
||||
f"Insufficient credits. Required: {required}, Available: {account.credits}"
|
||||
f"Insufficient credits. Required: {required}, Available: {total_available}"
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -286,6 +290,8 @@ class CreditService:
|
||||
Legacy method to check credits for a known amount.
|
||||
Used internally by deduct_credits.
|
||||
|
||||
Uses total_credits (plan + bonus) for the check.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
amount: Required credits amount
|
||||
@@ -293,9 +299,10 @@ class CreditService:
|
||||
Raises:
|
||||
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(
|
||||
f"Insufficient credits. Required: {amount}, Available: {account.credits}"
|
||||
f"Insufficient credits. Required: {amount}, Available: {total_available}"
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -357,6 +364,12 @@ class CreditService:
|
||||
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None, site=None):
|
||||
"""
|
||||
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:
|
||||
account: Account instance
|
||||
@@ -373,26 +386,43 @@ class CreditService:
|
||||
site: Optional Site instance or site_id
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
int: New total credit balance (plan + bonus)
|
||||
"""
|
||||
# Check sufficient credits (legacy: amount is already calculated)
|
||||
CreditService.check_credits_legacy(account, amount)
|
||||
# Check sufficient credits using total (plan + bonus)
|
||||
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
|
||||
previous_balance = account.credits
|
||||
previous_total = account.credits + account.bonus_credits
|
||||
|
||||
# Calculate how much to deduct from each pool
|
||||
# Plan credits first, then bonus credits
|
||||
from_plan = min(account.credits, amount)
|
||||
from_bonus = amount - from_plan
|
||||
|
||||
# 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'])
|
||||
|
||||
# Deduct from account.credits
|
||||
account.credits -= amount
|
||||
account.save(update_fields=['credits'])
|
||||
|
||||
# Create CreditTransaction
|
||||
# Create CreditTransaction with details about source
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type='deduction',
|
||||
amount=-amount, # Negative for deduction
|
||||
balance_after=account.credits,
|
||||
balance_after=account.credits + account.bonus_credits,
|
||||
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
|
||||
@@ -419,13 +449,17 @@ class CreditService:
|
||||
tokens_output=tokens_output,
|
||||
related_object_type=related_object_type or '',
|
||||
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_low_credits_warning(account, previous_balance)
|
||||
_check_low_credits_warning(account, previous_total)
|
||||
|
||||
return account.credits
|
||||
return account.credits + account.bonus_credits
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
@@ -538,15 +572,62 @@ class CreditService:
|
||||
|
||||
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
|
||||
@transaction.atomic
|
||||
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.
|
||||
|
||||
IMPORTANT: Bonus credits are NOT affected by this reset.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
new_amount: Number of credits to set (plan's included_credits)
|
||||
@@ -554,36 +635,37 @@ class CreditService:
|
||||
metadata: Optional metadata dict
|
||||
|
||||
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.save(update_fields=['credits'])
|
||||
account.save(update_fields=['credits']) # Only update plan credits
|
||||
|
||||
# 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
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type='subscription', # Uses 'Subscription Renewal' display
|
||||
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,
|
||||
metadata={
|
||||
**(metadata or {}),
|
||||
'reset_from': old_balance,
|
||||
'reset_from': old_plan_balance,
|
||||
'reset_to': new_amount,
|
||||
'is_renewal_reset': True
|
||||
'is_renewal_reset': True,
|
||||
'bonus_credits_unchanged': account.bonus_credits,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Credits reset for renewal: Account {account.id} - "
|
||||
f"from {old_balance} to {new_amount} (change: {change_amount})"
|
||||
f"Plan credits reset for renewal: Account {account.id} - "
|
||||
f"Plan: {old_plan_balance} → {new_amount}, Bonus: {account.bonus_credits} (unchanged)"
|
||||
)
|
||||
|
||||
return account.credits
|
||||
return account.credits + account.bonus_credits
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
|
||||
@@ -1127,6 +1127,147 @@ To view and pay your invoice:
|
||||
|
||||
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,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
@@ -1222,6 +1363,60 @@ Please update your payment method to continue your subscription:
|
||||
|
||||
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,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
|
||||
@@ -13,6 +13,30 @@ from ....auth.models import Account, Subscription
|
||||
|
||||
class InvoiceService:
|
||||
"""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
|
||||
def get_pending_invoice(subscription: Subscription) -> Optional[Invoice]:
|
||||
@@ -126,6 +150,7 @@ class InvoiceService:
|
||||
account=account,
|
||||
subscription=subscription, # Set FK directly
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
invoice_type='subscription',
|
||||
status='pending',
|
||||
currency=currency,
|
||||
invoice_date=invoice_date,
|
||||
@@ -174,6 +199,7 @@ class InvoiceService:
|
||||
|
||||
# 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.config import CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS
|
||||
|
||||
currency = 'USD'
|
||||
usd_price = float(credit_package.price)
|
||||
@@ -192,10 +218,12 @@ class InvoiceService:
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
invoice_type='credit_package',
|
||||
status='pending',
|
||||
currency=currency,
|
||||
invoice_date=invoice_date,
|
||||
due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET),
|
||||
expires_at=timezone.now() + timedelta(hours=CREDIT_PACKAGE_INVOICE_EXPIRY_HOURS),
|
||||
metadata={
|
||||
'billing_snapshot': {
|
||||
'email': billing_email,
|
||||
@@ -255,6 +283,7 @@ class InvoiceService:
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
invoice_type='custom',
|
||||
status='draft',
|
||||
currency='USD',
|
||||
notes=notes,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Payment Service - Handles payment processing across multiple gateways
|
||||
"""
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
from django.db import transaction
|
||||
@@ -9,6 +10,9 @@ from django.utils import timezone
|
||||
from ..models import Payment, Invoice, CreditPackage, PaymentMethodConfig, CreditTransaction
|
||||
from ....auth.models import Account
|
||||
|
||||
# Use dedicated payment logger (logs to /logs/billing-logs/payments.log)
|
||||
logger = logging.getLogger('payments')
|
||||
|
||||
|
||||
class PaymentService:
|
||||
"""Service for processing payments across multiple gateways"""
|
||||
@@ -24,6 +28,7 @@ class PaymentService:
|
||||
"""
|
||||
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(
|
||||
account=invoice.account,
|
||||
invoice=invoice,
|
||||
@@ -35,7 +40,7 @@ class PaymentService:
|
||||
stripe_charge_id=stripe_charge_id,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
logger.info(f"Created Stripe payment {payment.id} for account {invoice.account_id}")
|
||||
return payment
|
||||
|
||||
@staticmethod
|
||||
@@ -48,6 +53,7 @@ class PaymentService:
|
||||
"""
|
||||
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(
|
||||
account=invoice.account,
|
||||
invoice=invoice,
|
||||
@@ -182,16 +188,23 @@ class PaymentService:
|
||||
transaction_id=payment.transaction_reference
|
||||
)
|
||||
|
||||
# If payment is for credit package, add credits
|
||||
if payment.metadata.get('credit_package_id'):
|
||||
PaymentService._add_credits_for_payment(payment)
|
||||
|
||||
# If account is inactive/suspended/trial, activate it on successful payment
|
||||
# Apply fulfillment based on invoice type
|
||||
try:
|
||||
account = payment.account
|
||||
if account and account.status != 'active':
|
||||
account.status = 'active'
|
||||
account.save(update_fields=['status', 'updated_at'])
|
||||
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':
|
||||
account.status = 'active'
|
||||
account.save(update_fields=['status', 'updated_at'])
|
||||
except Exception:
|
||||
# Do not block payment approval if status update fails
|
||||
pass
|
||||
@@ -222,9 +235,16 @@ class PaymentService:
|
||||
@staticmethod
|
||||
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')
|
||||
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:
|
||||
return
|
||||
|
||||
@@ -233,18 +253,13 @@ class PaymentService:
|
||||
except CreditPackage.DoesNotExist:
|
||||
return
|
||||
|
||||
# Update account balance
|
||||
account: Account = payment.account
|
||||
account.credits = (account.credits or 0) + credit_package.credits
|
||||
account.save(update_fields=['credits', 'updated_at'])
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
|
||||
# Create credit transaction
|
||||
CreditTransaction.objects.create(
|
||||
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
|
||||
CreditService.add_bonus_credits(
|
||||
account=payment.account,
|
||||
amount=credit_package.credits,
|
||||
transaction_type='purchase',
|
||||
description=f"Purchased {credit_package.name}",
|
||||
reference_id=str(payment.id),
|
||||
description=f"Purchased {credit_package.name} ({credit_package.credits} bonus credits)",
|
||||
metadata={
|
||||
'payment_id': payment.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
|
||||
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 django.db import transaction
|
||||
from django.utils import timezone
|
||||
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.email_service import BillingEmailService
|
||||
from igny8_core.business.billing.config import SUBSCRIPTION_RENEWAL_NOTICE_DAYS
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Grace period in days for manual payment before subscription expires
|
||||
RENEWAL_GRACE_PERIOD_DAYS = 7
|
||||
# Days between invoice reminder emails
|
||||
INVOICE_REMINDER_INTERVAL_DAYS = 3
|
||||
# Days before renewal to generate invoice for bank transfer (manual payment)
|
||||
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')
|
||||
def send_renewal_notices():
|
||||
@shared_task(name='billing.create_bank_transfer_invoices')
|
||||
def create_bank_transfer_invoices():
|
||||
"""
|
||||
Send renewal notice emails to subscribers
|
||||
Run daily to check subscriptions expiring soon
|
||||
"""
|
||||
notice_date = timezone.now().date() + timedelta(days=SUBSCRIPTION_RENEWAL_NOTICE_DAYS)
|
||||
Create invoices for bank transfer users 3 days before renewal.
|
||||
Run daily at 09:00.
|
||||
|
||||
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(
|
||||
status='active',
|
||||
current_period_end__date=notice_date
|
||||
).select_related('account', 'plan', 'account__owner')
|
||||
current_period_end__date=invoice_date
|
||||
).select_related('account', 'plan')
|
||||
|
||||
created_count = 0
|
||||
for subscription in subscriptions:
|
||||
# Check if notice already sent
|
||||
if subscription.metadata.get('renewal_notice_sent'):
|
||||
# Only for bank transfer / manual payment users
|
||||
if not _is_manual_payment_account(subscription):
|
||||
continue
|
||||
|
||||
# Skip if advance invoice already created
|
||||
if subscription.metadata.get('advance_invoice_created'):
|
||||
continue
|
||||
|
||||
try:
|
||||
BillingEmailService.send_subscription_renewal_notice(
|
||||
subscription=subscription,
|
||||
days_until_renewal=SUBSCRIPTION_RENEWAL_NOTICE_DAYS
|
||||
# Create advance invoice
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
account=subscription.account,
|
||||
subscription=subscription
|
||||
)
|
||||
|
||||
# Mark notice as sent
|
||||
subscription.metadata['renewal_notice_sent'] = True
|
||||
subscription.metadata['renewal_notice_sent_at'] = timezone.now().isoformat()
|
||||
# Mark advance invoice created
|
||||
subscription.metadata['advance_invoice_created'] = True
|
||||
subscription.metadata['advance_invoice_id'] = invoice.id
|
||||
subscription.metadata['advance_invoice_created_at'] = now.isoformat()
|
||||
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:
|
||||
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')
|
||||
def process_subscription_renewals():
|
||||
"""
|
||||
Process subscription renewals for subscriptions ending today
|
||||
Run daily at midnight to renew subscriptions
|
||||
Process subscription renewals for subscriptions ending today.
|
||||
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()
|
||||
|
||||
@@ -82,7 +151,12 @@ def process_subscription_renewals():
|
||||
@shared_task(name='billing.renew_subscription')
|
||||
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:
|
||||
subscription_id: Subscription ID to renew
|
||||
@@ -95,44 +169,91 @@ def renew_subscription(subscription_id: int):
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# Create renewal invoice
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
account=subscription.account,
|
||||
subscription=subscription
|
||||
)
|
||||
# Check if this is a manual payment account with advance invoice
|
||||
is_manual = _is_manual_payment_account(subscription)
|
||||
|
||||
# Attempt automatic payment if payment method on file
|
||||
payment_attempted = False
|
||||
|
||||
# Check if account has saved payment method for automatic billing
|
||||
if subscription.metadata.get('stripe_subscription_id'):
|
||||
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
|
||||
elif subscription.metadata.get('paypal_subscription_id'):
|
||||
payment_attempted = _attempt_paypal_renewal(subscription, invoice)
|
||||
|
||||
if payment_attempted:
|
||||
# Payment processing will handle subscription renewal
|
||||
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
|
||||
else:
|
||||
# No automatic payment - send invoice for manual payment
|
||||
# This handles all payment methods: bank_transfer, local_wallet, manual
|
||||
logger.info(f"Manual payment required for subscription {subscription_id}")
|
||||
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
|
||||
|
||||
# Mark subscription as pending renewal with grace period
|
||||
# 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_invoice_id'] = invoice.id
|
||||
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 invoice email for manual payment
|
||||
try:
|
||||
BillingEmailService.send_invoice_email(invoice, is_reminder=False)
|
||||
logger.info(f"Invoice email sent for subscription {subscription_id}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send invoice email for subscription {subscription_id}: {str(e)}")
|
||||
# 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(
|
||||
account=subscription.account,
|
||||
subscription=subscription
|
||||
)
|
||||
|
||||
payment_attempted = False
|
||||
|
||||
if subscription.metadata.get('stripe_subscription_id'):
|
||||
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
|
||||
elif subscription.metadata.get('paypal_subscription_id'):
|
||||
payment_attempted = _attempt_paypal_renewal(subscription, invoice)
|
||||
|
||||
if payment_attempted:
|
||||
# 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}")
|
||||
else:
|
||||
# Auto-payment failed - mark as pending with Pay Now option
|
||||
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_invoice_id'] = invoice.id
|
||||
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['auto_payment_failed'] = True
|
||||
subscription.save(update_fields=['status', 'metadata'])
|
||||
|
||||
try:
|
||||
BillingEmailService.send_invoice_email(
|
||||
invoice,
|
||||
is_reminder=False,
|
||||
extra_context={'auto_payment_failed': True, 'show_pay_now': True}
|
||||
)
|
||||
except Exception as 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
|
||||
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)}")
|
||||
|
||||
|
||||
@shared_task(name='billing.send_invoice_reminders')
|
||||
def send_invoice_reminders():
|
||||
def _complete_renewal(subscription: Subscription, invoice: Invoice):
|
||||
"""
|
||||
Send invoice reminder emails for pending renewals
|
||||
Run daily to remind accounts with pending invoices
|
||||
Complete a successful renewal - reset plan credits to full amount.
|
||||
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()
|
||||
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(
|
||||
status='pending_renewal'
|
||||
).select_related('account', 'plan')
|
||||
|
||||
reminder_count = 0
|
||||
for subscription in subscriptions:
|
||||
# Check if enough time has passed since last reminder
|
||||
last_reminder = subscription.metadata.get('last_invoice_reminder_at')
|
||||
if last_reminder:
|
||||
from datetime import datetime
|
||||
last_reminder_dt = datetime.fromisoformat(last_reminder.replace('Z', '+00:00'))
|
||||
if hasattr(last_reminder_dt, 'tzinfo') and last_reminder_dt.tzinfo is None:
|
||||
last_reminder_dt = timezone.make_aware(last_reminder_dt)
|
||||
if last_reminder_dt > reminder_threshold:
|
||||
# Only send if renewal was today (Day 0)
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
# Get the renewal invoice
|
||||
@@ -254,6 +460,117 @@ def check_expired_renewals():
|
||||
|
||||
if expired_count > 0:
|
||||
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:
|
||||
|
||||
@@ -22,8 +22,21 @@ app.autodiscover_tasks()
|
||||
# Explicitly import tasks from igny8_core/tasks directory
|
||||
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
|
||||
# NOTE: Do NOT set static task_id in options - it causes results to overwrite instead of creating history
|
||||
app.conf.beat_schedule = {
|
||||
# Monthly Credits
|
||||
'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
|
||||
@@ -38,21 +51,37 @@ app.conf.beat_schedule = {
|
||||
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users
|
||||
},
|
||||
# Subscription Renewal Tasks
|
||||
'send-renewal-notices': {
|
||||
'task': 'billing.send_renewal_notices',
|
||||
'schedule': crontab(hour=9, minute=0), # Daily at 09:00
|
||||
# Stripe/PayPal: No advance notice (industry standard) - auto-pay happens on renewal day
|
||||
# Bank Transfer: Invoice created 3 days before, reminders on Day 0 and Day +1
|
||||
'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': {
|
||||
'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': {
|
||||
'task': 'billing.send_invoice_reminders',
|
||||
'schedule': crontab(hour=10, minute=0), # Daily at 10:00
|
||||
'send-renewal-day-reminders': {
|
||||
'task': 'billing.send_renewal_day_reminders',
|
||||
'schedule': crontab(hour=10, minute=0), # Daily at 10:00 - Day 0 reminder for bank transfer
|
||||
},
|
||||
'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
|
||||
'check-scheduled-automations': {
|
||||
@@ -61,7 +90,7 @@ app.conf.beat_schedule = {
|
||||
},
|
||||
'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
|
||||
'schedule-approved-content': {
|
||||
|
||||
@@ -16,12 +16,35 @@ from igny8_core.business.billing.models import (
|
||||
PaymentMethodConfig,
|
||||
PlanLimitUsage,
|
||||
AIModelConfig,
|
||||
WebhookEvent,
|
||||
)
|
||||
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
|
||||
from import_export.admin import ExportMixin, ImportExportMixin
|
||||
from import_export import resources
|
||||
|
||||
|
||||
def _get_invoice_type(invoice):
|
||||
if invoice and getattr(invoice, 'invoice_type', None):
|
||||
return invoice.invoice_type
|
||||
if invoice and invoice.metadata and invoice.metadata.get('credit_package_id'):
|
||||
return 'credit_package'
|
||||
if invoice and getattr(invoice, 'subscription_id', None):
|
||||
return 'subscription'
|
||||
return 'custom'
|
||||
|
||||
|
||||
def _get_credit_package(invoice):
|
||||
if not invoice or not invoice.metadata:
|
||||
return None
|
||||
credit_package_id = invoice.metadata.get('credit_package_id')
|
||||
if not credit_package_id:
|
||||
return None
|
||||
try:
|
||||
return CreditPackage.objects.get(id=credit_package_id)
|
||||
except CreditPackage.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class CreditTransactionResource(resources.ModelResource):
|
||||
"""Resource class for exporting Credit Transactions"""
|
||||
class Meta:
|
||||
@@ -272,87 +295,111 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
||||
invoice.status = 'paid'
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save()
|
||||
|
||||
# Update Subscription
|
||||
if subscription and subscription.status != 'active':
|
||||
|
||||
invoice_type = _get_invoice_type(invoice)
|
||||
|
||||
# Update Subscription (subscription invoices only)
|
||||
if invoice_type == 'subscription' and subscription and subscription.status != 'active':
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = obj.manual_reference
|
||||
subscription.save()
|
||||
|
||||
# Update Account
|
||||
if account.status != 'active':
|
||||
|
||||
# Update Account (subscription invoices only)
|
||||
if invoice_type == 'subscription' and account.status != 'active':
|
||||
account.status = 'active'
|
||||
account.save()
|
||||
|
||||
|
||||
# Add or Reset Credits (check if not already added)
|
||||
from igny8_core.business.billing.models import CreditTransaction, Invoice
|
||||
existing_credit = CreditTransaction.objects.filter(
|
||||
account=account,
|
||||
metadata__payment_id=obj.id
|
||||
).exists()
|
||||
|
||||
|
||||
if not existing_credit:
|
||||
credits_to_add = 0
|
||||
plan_name = ''
|
||||
is_renewal = False
|
||||
|
||||
if subscription and subscription.plan:
|
||||
credits_to_add = subscription.plan.included_credits
|
||||
plan_name = subscription.plan.name
|
||||
# Check if this is a renewal (previous paid invoices exist)
|
||||
previous_paid = Invoice.objects.filter(
|
||||
subscription=subscription,
|
||||
status='paid'
|
||||
).exclude(id=invoice.id if invoice else None).exists()
|
||||
is_renewal = previous_paid
|
||||
elif account and account.plan:
|
||||
credits_to_add = account.plan.included_credits
|
||||
plan_name = account.plan.name
|
||||
# Check renewal by account history
|
||||
is_renewal = CreditTransaction.objects.filter(
|
||||
account=account,
|
||||
transaction_type='subscription'
|
||||
).exists()
|
||||
|
||||
if credits_to_add > 0:
|
||||
if is_renewal:
|
||||
# Renewal: Reset credits to full plan amount
|
||||
CreditService.reset_credits_for_renewal(
|
||||
account=account,
|
||||
new_amount=credits_to_add,
|
||||
description=f'{plan_name} Renewal - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id if subscription else None,
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': obj.id,
|
||||
'approved_by': request.user.email,
|
||||
'is_renewal': True
|
||||
}
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f'✓ Renewal approved: Account activated, credits reset to {credits_to_add}',
|
||||
level='SUCCESS'
|
||||
)
|
||||
else:
|
||||
# Initial: Add credits
|
||||
CreditService.add_credits(
|
||||
|
||||
if invoice_type == 'credit_package':
|
||||
package = _get_credit_package(invoice)
|
||||
if package:
|
||||
credits_to_add = package.credits
|
||||
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
|
||||
CreditService.add_bonus_credits(
|
||||
account=account,
|
||||
amount=credits_to_add,
|
||||
transaction_type='subscription',
|
||||
description=f'{plan_name} - Invoice {invoice.invoice_number}',
|
||||
description=f'Credit package: {package.name} ({credits_to_add} bonus credits) - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id if subscription else None,
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': obj.id,
|
||||
'credit_package_id': str(package.id),
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f'✓ Payment approved: Account activated, {credits_to_add} credits added',
|
||||
request,
|
||||
f'✓ Credit package approved: {credits_to_add} bonus credits added (never expire)',
|
||||
level='SUCCESS'
|
||||
)
|
||||
else:
|
||||
if subscription and subscription.plan:
|
||||
credits_to_add = subscription.plan.included_credits
|
||||
plan_name = subscription.plan.name
|
||||
# Check if this is a renewal (previous paid invoices exist)
|
||||
previous_paid = Invoice.objects.filter(
|
||||
subscription=subscription,
|
||||
status='paid'
|
||||
).exclude(id=invoice.id if invoice else None).exists()
|
||||
is_renewal = previous_paid
|
||||
elif account and account.plan:
|
||||
credits_to_add = account.plan.included_credits
|
||||
plan_name = account.plan.name
|
||||
# Check renewal by account history
|
||||
is_renewal = CreditTransaction.objects.filter(
|
||||
account=account,
|
||||
transaction_type='subscription'
|
||||
).exists()
|
||||
|
||||
if credits_to_add > 0:
|
||||
if is_renewal:
|
||||
# Renewal: Reset credits to full plan amount
|
||||
CreditService.reset_credits_for_renewal(
|
||||
account=account,
|
||||
new_amount=credits_to_add,
|
||||
description=f'{plan_name} Renewal - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id if subscription else None,
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': obj.id,
|
||||
'approved_by': request.user.email,
|
||||
'is_renewal': True
|
||||
}
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f'✓ Renewal approved: Account activated, credits reset to {credits_to_add}',
|
||||
level='SUCCESS'
|
||||
)
|
||||
else:
|
||||
# Initial: Add credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_to_add,
|
||||
transaction_type='subscription',
|
||||
description=f'{plan_name} - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id if subscription else None,
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': obj.id,
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f'✓ Payment approved: Account activated, {credits_to_add} credits added',
|
||||
level='SUCCESS'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
@@ -400,15 +447,18 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save()
|
||||
|
||||
# Update Subscription
|
||||
if subscription:
|
||||
invoice_type = _get_invoice_type(invoice)
|
||||
|
||||
# Update Subscription (subscription invoices only)
|
||||
if invoice_type == 'subscription' and subscription:
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = payment.manual_reference
|
||||
subscription.save()
|
||||
|
||||
# Update Account
|
||||
account.status = 'active'
|
||||
account.save()
|
||||
# Update Account (subscription invoices only)
|
||||
if invoice_type == 'subscription' and account.status != 'active':
|
||||
account.status = 'active'
|
||||
account.save()
|
||||
|
||||
# Add or Reset Credits based on whether this is a renewal
|
||||
# Check if there are previous paid invoices for this subscription (renewal)
|
||||
@@ -420,9 +470,25 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
||||
status='paid'
|
||||
).exclude(id=invoice.id).exists()
|
||||
is_renewal = previous_paid_invoices
|
||||
|
||||
|
||||
credits_added = 0
|
||||
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
||||
if invoice_type == 'credit_package':
|
||||
package = _get_credit_package(invoice)
|
||||
if package:
|
||||
credits_added = package.credits
|
||||
# Use add_bonus_credits for credit packages (never expire, not affected by renewal)
|
||||
CreditService.add_bonus_credits(
|
||||
account=account,
|
||||
amount=credits_added,
|
||||
description=f'Credit package: {package.name} ({credits_added} bonus credits) - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': payment.id,
|
||||
'credit_package_id': str(package.id),
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
elif subscription and subscription.plan and subscription.plan.included_credits > 0:
|
||||
credits_added = subscription.plan.included_credits
|
||||
if is_renewal:
|
||||
# Renewal: Reset credits to full plan amount
|
||||
@@ -1050,3 +1116,167 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
count = queryset.update(is_testing=False)
|
||||
self.message_user(request, f'{count} model(s) unmarked as testing.', messages.SUCCESS)
|
||||
unset_testing.short_description = 'Unset testing flag'
|
||||
|
||||
|
||||
class WebhookEventResource(resources.ModelResource):
|
||||
"""Resource class for exporting Webhook Events"""
|
||||
class Meta:
|
||||
model = WebhookEvent
|
||||
fields = ('id', 'event_id', 'provider', 'event_type', 'processed', 'processed_at',
|
||||
'error_message', 'retry_count', 'created_at')
|
||||
export_order = fields
|
||||
|
||||
|
||||
@admin.register(WebhookEvent)
|
||||
class WebhookEventAdmin(ExportMixin, Igny8ModelAdmin):
|
||||
"""
|
||||
Payment Logs Admin - Centralized view of all payment webhook events
|
||||
Shows Stripe and PayPal payment events with processing status
|
||||
"""
|
||||
resource_class = WebhookEventResource
|
||||
list_display = [
|
||||
'event_id_short',
|
||||
'provider_badge',
|
||||
'event_type_display',
|
||||
'processed_badge',
|
||||
'processing_time',
|
||||
'retry_count',
|
||||
'created_at',
|
||||
]
|
||||
list_filter = ['provider', 'event_type', 'processed', 'created_at']
|
||||
search_fields = ['event_id', 'event_type', 'error_message']
|
||||
readonly_fields = ['event_id', 'provider', 'event_type', 'payload_formatted', 'processed',
|
||||
'processed_at', 'error_message', 'retry_count', 'created_at']
|
||||
date_hierarchy = 'created_at'
|
||||
ordering = ['-created_at']
|
||||
|
||||
fieldsets = (
|
||||
('Event Info', {
|
||||
'fields': ('event_id', 'provider', 'event_type', 'created_at')
|
||||
}),
|
||||
('Processing Status', {
|
||||
'fields': ('processed', 'processed_at', 'retry_count', 'error_message'),
|
||||
}),
|
||||
('Payload', {
|
||||
'fields': ('payload_formatted',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ['mark_as_processed', 'retry_processing']
|
||||
|
||||
def event_id_short(self, obj):
|
||||
"""Show truncated event ID"""
|
||||
return obj.event_id[:30] + '...' if len(obj.event_id) > 30 else obj.event_id
|
||||
event_id_short.short_description = 'Event ID'
|
||||
|
||||
def provider_badge(self, obj):
|
||||
"""Show provider as a colored badge"""
|
||||
colors = {
|
||||
'stripe': '#635BFF', # Stripe purple
|
||||
'paypal': '#003087', # PayPal blue
|
||||
}
|
||||
color = colors.get(obj.provider, '#666')
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 8px; '
|
||||
'border-radius: 4px; font-size: 11px; font-weight: bold;">{}</span>',
|
||||
color,
|
||||
obj.provider.upper()
|
||||
)
|
||||
provider_badge.short_description = 'Provider'
|
||||
|
||||
def event_type_display(self, obj):
|
||||
"""Show event type with friendly formatting"""
|
||||
# Map common event types to friendly names
|
||||
friendly_names = {
|
||||
'checkout.session.completed': 'Checkout Completed',
|
||||
'payment_intent.succeeded': 'Payment Succeeded',
|
||||
'payment_intent.payment_failed': 'Payment Failed',
|
||||
'invoice.paid': 'Invoice Paid',
|
||||
'invoice.payment_failed': 'Invoice Payment Failed',
|
||||
'customer.subscription.created': 'Subscription Created',
|
||||
'customer.subscription.updated': 'Subscription Updated',
|
||||
'customer.subscription.deleted': 'Subscription Cancelled',
|
||||
'PAYMENT.CAPTURE.COMPLETED': 'Payment Captured',
|
||||
'PAYMENT.CAPTURE.DENIED': 'Payment Denied',
|
||||
'PAYMENT.CAPTURE.REFUNDED': 'Payment Refunded',
|
||||
'BILLING.SUBSCRIPTION.ACTIVATED': 'Subscription Activated',
|
||||
'BILLING.SUBSCRIPTION.CANCELLED': 'Subscription Cancelled',
|
||||
}
|
||||
friendly = friendly_names.get(obj.event_type, obj.event_type)
|
||||
return format_html('<span title="{}">{}</span>', obj.event_type, friendly)
|
||||
event_type_display.short_description = 'Event Type'
|
||||
|
||||
def processed_badge(self, obj):
|
||||
"""Show processing status as badge"""
|
||||
if obj.processed:
|
||||
return format_html(
|
||||
'<span style="background-color: #10B981; color: white; padding: 3px 8px; '
|
||||
'border-radius: 4px; font-size: 11px;">✓ Processed</span>'
|
||||
)
|
||||
elif obj.error_message:
|
||||
return format_html(
|
||||
'<span style="background-color: #EF4444; color: white; padding: 3px 8px; '
|
||||
'border-radius: 4px; font-size: 11px;">✗ Failed</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="background-color: #F59E0B; color: white; padding: 3px 8px; '
|
||||
'border-radius: 4px; font-size: 11px;">⏳ Pending</span>'
|
||||
)
|
||||
processed_badge.short_description = 'Status'
|
||||
|
||||
def processing_time(self, obj):
|
||||
"""Show time taken to process"""
|
||||
if obj.processed and obj.processed_at:
|
||||
delta = obj.processed_at - obj.created_at
|
||||
ms = delta.total_seconds() * 1000
|
||||
if ms < 1000:
|
||||
return f'{ms:.0f}ms'
|
||||
return f'{delta.total_seconds():.1f}s'
|
||||
return '-'
|
||||
processing_time.short_description = 'Process Time'
|
||||
|
||||
def payload_formatted(self, obj):
|
||||
"""Show formatted JSON payload"""
|
||||
import json
|
||||
try:
|
||||
formatted = json.dumps(obj.payload, indent=2)
|
||||
return format_html(
|
||||
'<pre style="max-height: 400px; overflow: auto; background: #f5f5f5; '
|
||||
'padding: 10px; border-radius: 4px; font-size: 12px;">{}</pre>',
|
||||
formatted
|
||||
)
|
||||
except:
|
||||
return str(obj.payload)
|
||||
payload_formatted.short_description = 'Payload'
|
||||
|
||||
def mark_as_processed(self, request, queryset):
|
||||
"""Mark selected events as processed"""
|
||||
from django.utils import timezone
|
||||
count = queryset.update(processed=True, processed_at=timezone.now())
|
||||
self.message_user(request, f'{count} event(s) marked as processed.', messages.SUCCESS)
|
||||
mark_as_processed.short_description = 'Mark as processed'
|
||||
|
||||
def retry_processing(self, request, queryset):
|
||||
"""Queue selected events for reprocessing"""
|
||||
count = 0
|
||||
for event in queryset.filter(processed=False):
|
||||
# TODO: Implement actual reprocessing logic based on event type
|
||||
event.retry_count += 1
|
||||
event.save()
|
||||
count += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f'{count} event(s) queued for reprocessing. (Manual reprocessing required)',
|
||||
messages.INFO
|
||||
)
|
||||
retry_processing.short_description = 'Retry processing'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Webhook events should only be created by webhooks"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Allow viewing but restrict editing"""
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from igny8_core.business.billing.models import Invoice, Payment, CreditTransaction, CreditPackage
|
||||
from igny8_core.auth.models import Account
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Audit invoice/payment/credits for a purchase"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--invoice-number", dest="invoice_number", help="Invoice number (e.g., INV-26010008)")
|
||||
parser.add_argument("--invoice-id", dest="invoice_id", type=int, help="Invoice ID")
|
||||
parser.add_argument("--payment-id", dest="payment_id", type=int, help="Payment ID")
|
||||
parser.add_argument("--account-id", dest="account_id", type=int, help="Account ID (optional)")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
invoice_number = options.get("invoice_number")
|
||||
invoice_id = options.get("invoice_id")
|
||||
payment_id = options.get("payment_id")
|
||||
account_id = options.get("account_id")
|
||||
|
||||
if not any([invoice_number, invoice_id, payment_id, account_id]):
|
||||
self.stderr.write("Provide at least one filter: --invoice-number, --invoice-id, --payment-id, --account-id")
|
||||
return
|
||||
|
||||
invoice_qs = Invoice.objects.all().select_related("account", "subscription", "subscription__plan")
|
||||
payment_qs = Payment.objects.all().select_related("account", "invoice")
|
||||
|
||||
invoice = None
|
||||
if invoice_number:
|
||||
invoice = invoice_qs.filter(invoice_number=invoice_number).first()
|
||||
elif invoice_id:
|
||||
invoice = invoice_qs.filter(id=invoice_id).first()
|
||||
elif payment_id:
|
||||
payment = payment_qs.filter(id=payment_id).first()
|
||||
invoice = payment.invoice if payment else None
|
||||
elif account_id:
|
||||
invoice = invoice_qs.filter(account_id=account_id).order_by("-created_at").first()
|
||||
|
||||
if not invoice:
|
||||
self.stderr.write("No invoice found for the provided filter.")
|
||||
return
|
||||
|
||||
account = invoice.account
|
||||
invoice_type = invoice.invoice_type
|
||||
credit_package_id = (invoice.metadata or {}).get("credit_package_id")
|
||||
credit_package = None
|
||||
if credit_package_id:
|
||||
credit_package = CreditPackage.objects.filter(id=credit_package_id).first()
|
||||
|
||||
self.stdout.write("=== INVOICE ===")
|
||||
self.stdout.write(f"Invoice: {invoice.invoice_number} (ID={invoice.id})")
|
||||
self.stdout.write(f"Type: {invoice_type}")
|
||||
self.stdout.write(f"Status: {invoice.status}")
|
||||
self.stdout.write(f"Total: {invoice.total} {invoice.currency}")
|
||||
self.stdout.write(f"Paid at: {invoice.paid_at}")
|
||||
self.stdout.write(f"Expires at: {invoice.expires_at}")
|
||||
self.stdout.write(f"Void reason: {invoice.void_reason}")
|
||||
self.stdout.write(f"Account: {account.id} - {account.name}")
|
||||
self.stdout.write(f"Account credits: {account.credits}")
|
||||
if invoice.subscription:
|
||||
plan = invoice.subscription.plan
|
||||
self.stdout.write(f"Subscription: {invoice.subscription.id} (status={invoice.subscription.status})")
|
||||
self.stdout.write(f"Plan: {plan.id if plan else None} - {plan.name if plan else None}")
|
||||
if credit_package:
|
||||
self.stdout.write(f"Credit Package: {credit_package.id} - {credit_package.name} ({credit_package.credits} credits)")
|
||||
|
||||
payments = payment_qs.filter(invoice_id=invoice.id).order_by("created_at")
|
||||
self.stdout.write("\n=== PAYMENTS ===")
|
||||
if not payments.exists():
|
||||
self.stdout.write("No payments found for this invoice.")
|
||||
else:
|
||||
for pay in payments:
|
||||
self.stdout.write(
|
||||
f"Payment {pay.id}: status={pay.status}, method={pay.payment_method}, amount={pay.amount} {pay.currency}, processed_at={pay.processed_at}"
|
||||
)
|
||||
|
||||
credit_transactions = CreditTransaction.objects.filter(account=account).order_by("-created_at")[:50]
|
||||
related_transactions = CreditTransaction.objects.filter(
|
||||
account=account
|
||||
).filter(
|
||||
metadata__invoice_id=invoice.id
|
||||
).order_by("created_at")
|
||||
|
||||
self.stdout.write("\n=== CREDIT TRANSACTIONS (RELATED) ===")
|
||||
if not related_transactions.exists():
|
||||
self.stdout.write("No credit transactions linked to this invoice.")
|
||||
else:
|
||||
for tx in related_transactions:
|
||||
self.stdout.write(
|
||||
f"{tx.created_at}: {tx.transaction_type} amount={tx.amount} balance_after={tx.balance_after} desc={tx.description}"
|
||||
)
|
||||
|
||||
if not related_transactions.exists() and invoice_type == "credit_package" and invoice.status == "paid":
|
||||
self.stdout.write("\n!!! WARNING: Paid credit invoice with no linked credit transaction.")
|
||||
self.stdout.write("This indicates credits were not applied.")
|
||||
|
||||
self.stdout.write("\n=== RECENT CREDIT TRANSACTIONS (LAST 50) ===")
|
||||
for tx in credit_transactions:
|
||||
self.stdout.write(
|
||||
f"{tx.created_at}: {tx.transaction_type} amount={tx.amount} balance_after={tx.balance_after} desc={tx.description}"
|
||||
)
|
||||
|
||||
self.stdout.write("\nAudit completed at: " + timezone.now().isoformat())
|
||||
@@ -0,0 +1,55 @@
|
||||
from datetime import timedelta
|
||||
from django.db import migrations, models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def populate_invoice_type_and_expiry(apps, schema_editor):
|
||||
Invoice = apps.get_model('billing', 'Invoice')
|
||||
|
||||
for invoice in Invoice.objects.all().iterator():
|
||||
invoice_type = 'custom'
|
||||
if getattr(invoice, 'subscription_id', None):
|
||||
invoice_type = 'subscription'
|
||||
else:
|
||||
metadata = invoice.metadata or {}
|
||||
if metadata.get('credit_package_id'):
|
||||
invoice_type = 'credit_package'
|
||||
|
||||
invoice.invoice_type = invoice_type
|
||||
|
||||
if invoice_type == 'credit_package' and not invoice.expires_at:
|
||||
base_time = invoice.created_at or timezone.now()
|
||||
invoice.expires_at = base_time + timedelta(hours=48)
|
||||
|
||||
invoice.save(update_fields=['invoice_type', 'expires_at'])
|
||||
|
||||
|
||||
def reverse_populate_invoice_type_and_expiry(apps, schema_editor):
|
||||
Invoice = apps.get_model('billing', 'Invoice')
|
||||
Invoice.objects.all().update(invoice_type='custom', expires_at=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0035_aimodelconfig_is_testing_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_type',
|
||||
field=models.CharField(choices=[('subscription', 'Subscription'), ('credit_package', 'Credit Package'), ('addon', 'Add-on'), ('custom', 'Custom')], db_index=True, default='custom', max_length=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='expires_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='void_reason',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.RunPython(populate_invoice_type_and_expiry, reverse_populate_invoice_type_and_expiry),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-20 06:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0036_invoice_type_and_expiry'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='creditpackage',
|
||||
name='features',
|
||||
field=models.JSONField(blank=True, default=list, help_text='Bonus features or highlights'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='planlimitusage',
|
||||
name='limit_type',
|
||||
field=models.CharField(choices=[('content_ideas', 'Content Ideas'), ('images_basic', 'Basic Images'), ('images_premium', 'Premium Images'), ('image_prompts', 'Image Prompts')], db_index=True, help_text='Type of limit being tracked', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -18,7 +18,7 @@ def replenish_monthly_credits():
|
||||
Runs on the first day of each month at midnight.
|
||||
|
||||
For each active account with a plan:
|
||||
- Adds plan.included_credits to account.credits
|
||||
- Resets credits to plan.included_credits
|
||||
- Creates a CreditTransaction record
|
||||
- Logs the replenishment
|
||||
"""
|
||||
@@ -52,12 +52,11 @@ def replenish_monthly_credits():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Add credits using CreditService
|
||||
# Reset credits using CreditService
|
||||
with transaction.atomic():
|
||||
new_balance = CreditService.add_credits(
|
||||
new_balance = CreditService.reset_credits_for_renewal(
|
||||
account=account,
|
||||
amount=monthly_credits,
|
||||
transaction_type='subscription',
|
||||
new_amount=monthly_credits,
|
||||
description=f"Monthly credit replenishment - {plan.name} plan",
|
||||
metadata={
|
||||
'plan_id': plan.id,
|
||||
@@ -69,7 +68,7 @@ def replenish_monthly_credits():
|
||||
|
||||
logger.info(
|
||||
f"Account {account.id} ({account.name}): "
|
||||
f"Added {monthly_credits} credits (balance: {new_balance})"
|
||||
f"Reset credits to {monthly_credits} (balance: {new_balance})"
|
||||
)
|
||||
replenished += 1
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
ViewSet for credit balance operations
|
||||
Unified API Standard v1.0 compliant
|
||||
|
||||
Returns:
|
||||
- credits: Plan credits (reset on renewal)
|
||||
- bonus_credits: Purchased credits (never expire, never reset)
|
||||
- total_credits: Sum of plan + bonus credits
|
||||
- plan_credits_per_month: Plan's included credits
|
||||
- credits_used_this_month: Credits consumed this billing period
|
||||
- credits_remaining: Total available credits
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
authentication_classes = [JWTAuthentication]
|
||||
@@ -52,6 +60,8 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
||||
if not account:
|
||||
return success_response(data={
|
||||
'credits': 0,
|
||||
'bonus_credits': 0,
|
||||
'total_credits': 0,
|
||||
'plan_credits_per_month': 0,
|
||||
'credits_used_this_month': 0,
|
||||
'credits_remaining': 0,
|
||||
@@ -70,20 +80,25 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
# Plan credits (reset on renewal)
|
||||
credits = account.credits or 0
|
||||
credits_remaining = credits
|
||||
# Bonus credits (never expire, from credit package purchases)
|
||||
bonus_credits = account.bonus_credits or 0
|
||||
# Total available
|
||||
total_credits = credits + bonus_credits
|
||||
credits_remaining = total_credits
|
||||
|
||||
data = {
|
||||
'credits': credits,
|
||||
'bonus_credits': bonus_credits,
|
||||
'total_credits': total_credits,
|
||||
'plan_credits_per_month': plan_credits_per_month,
|
||||
'credits_used_this_month': credits_used_this_month,
|
||||
'credits_remaining': credits_remaining,
|
||||
}
|
||||
|
||||
# Validate and serialize data
|
||||
serializer = CreditBalanceSerializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return success_response(data=serializer.validated_data, request=request)
|
||||
# Validate and serialize data (skip serializer for now due to new fields)
|
||||
return success_response(data=data, request=request)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -682,7 +697,12 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save()
|
||||
|
||||
# 3. Get and activate subscription
|
||||
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||
from igny8_core.business.billing.models import CreditPackage
|
||||
|
||||
invoice_type = InvoiceService.get_invoice_type(invoice) if invoice else 'custom'
|
||||
|
||||
# 3. Get and activate subscription (subscription invoices only)
|
||||
subscription = None
|
||||
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
|
||||
subscription = invoice.subscription
|
||||
@@ -692,46 +712,86 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if subscription:
|
||||
if invoice_type == 'subscription' and subscription:
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = payment.manual_reference
|
||||
subscription.save(update_fields=['status', 'external_payment_id'])
|
||||
|
||||
# 4. CRITICAL: Set account status to active
|
||||
account.status = 'active'
|
||||
account.save(update_fields=['status'])
|
||||
|
||||
# 5. Add credits if plan has included credits
|
||||
|
||||
# 4. Set account status to active (subscription invoices only)
|
||||
if invoice_type == 'subscription' and account.status != 'active':
|
||||
account.status = 'active'
|
||||
account.save(update_fields=['status'])
|
||||
|
||||
# 5. Add/Reset credits based on invoice type
|
||||
credits_added = 0
|
||||
try:
|
||||
plan = None
|
||||
if subscription and subscription.plan:
|
||||
plan = subscription.plan
|
||||
elif account and account.plan:
|
||||
plan = account.plan
|
||||
|
||||
if plan and plan.included_credits > 0:
|
||||
credits_added = plan.included_credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_added,
|
||||
transaction_type='subscription',
|
||||
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number if invoice else "N/A"}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id if subscription else None,
|
||||
'invoice_id': invoice.id if invoice else None,
|
||||
'payment_id': payment.id,
|
||||
'plan_id': plan.id,
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
if invoice_type == 'credit_package':
|
||||
credit_package_id = invoice.metadata.get('credit_package_id') if invoice and invoice.metadata else None
|
||||
if credit_package_id:
|
||||
package = CreditPackage.objects.get(id=credit_package_id)
|
||||
credits_added = package.credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_added,
|
||||
transaction_type='purchase',
|
||||
description=f'Credit package: {package.name} ({credits_added} credits) - Invoice {invoice.invoice_number if invoice else "N/A"}',
|
||||
metadata={
|
||||
'invoice_id': invoice.id if invoice else None,
|
||||
'payment_id': payment.id,
|
||||
'credit_package_id': str(package.id),
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
elif invoice_type == 'subscription':
|
||||
plan = None
|
||||
if subscription and subscription.plan:
|
||||
plan = subscription.plan
|
||||
elif account and account.plan:
|
||||
plan = account.plan
|
||||
|
||||
if plan and plan.included_credits > 0:
|
||||
credits_added = plan.included_credits
|
||||
CreditService.reset_credits_for_renewal(
|
||||
account=account,
|
||||
new_amount=credits_added,
|
||||
description=f'{plan.name} plan credits - Invoice {invoice.invoice_number if invoice else "N/A"}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id if subscription else None,
|
||||
'invoice_id': invoice.id if invoice else None,
|
||||
'payment_id': payment.id,
|
||||
'plan_id': plan.id,
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
except Exception as credit_error:
|
||||
logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True)
|
||||
# Don't fail the approval if credits fail - account is still activated
|
||||
# Don't fail the approval if credits fail
|
||||
|
||||
logger.info(
|
||||
f'Payment approved: Payment {payment.id}, Account {account.id} set to active, '
|
||||
f'{credits_added} credits added'
|
||||
f'Payment approved: Payment {payment.id}, Account {account.id}, '
|
||||
f'invoice_type={invoice_type}, credits_added={credits_added}'
|
||||
)
|
||||
|
||||
# Log to WebhookEvent for unified payment logs
|
||||
from igny8_core.business.billing.models import WebhookEvent
|
||||
WebhookEvent.record_event(
|
||||
event_id=f'{payment.payment_method}-approved-{payment.id}-{timezone.now().timestamp()}',
|
||||
provider=payment.payment_method,
|
||||
event_type='payment.approved',
|
||||
payload={
|
||||
'payment_id': payment.id,
|
||||
'invoice_id': invoice.id if invoice else None,
|
||||
'invoice_number': invoice.invoice_number if invoice else None,
|
||||
'invoice_type': invoice_type,
|
||||
'account_id': account.id,
|
||||
'amount': str(payment.amount),
|
||||
'currency': payment.currency,
|
||||
'manual_reference': payment.manual_reference,
|
||||
'approved_by': request.user.email,
|
||||
'credits_added': credits_added,
|
||||
'subscription_id': subscription.id if subscription else None,
|
||||
},
|
||||
processed=True
|
||||
)
|
||||
|
||||
# 6. Send approval email
|
||||
@@ -783,6 +843,24 @@ class AdminBillingViewSet(viewsets.ViewSet):
|
||||
|
||||
logger.info(f'Payment rejected: Payment {payment.id}, Reason: {rejection_reason}')
|
||||
|
||||
# Log to WebhookEvent for unified payment logs
|
||||
from igny8_core.business.billing.models import WebhookEvent
|
||||
WebhookEvent.record_event(
|
||||
event_id=f'{payment.payment_method}-rejected-{payment.id}-{timezone.now().timestamp()}',
|
||||
provider=payment.payment_method,
|
||||
event_type='payment.rejected',
|
||||
payload={
|
||||
'payment_id': payment.id,
|
||||
'account_id': account.id if account else None,
|
||||
'amount': str(payment.amount),
|
||||
'currency': payment.currency,
|
||||
'manual_reference': payment.manual_reference,
|
||||
'rejected_by': request.user.email,
|
||||
'rejection_reason': rejection_reason,
|
||||
},
|
||||
processed=True
|
||||
)
|
||||
|
||||
# Send rejection email
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
|
||||
@@ -550,7 +550,9 @@ CELERY_REDIS_BACKEND_USE_SSL = os.getenv('REDIS_SSL_ENABLED', 'false').lower() =
|
||||
|
||||
# Publish/Sync Logging Configuration
|
||||
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(BILLING_LOG_DIR, exist_ok=True)
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
@@ -566,6 +568,11 @@ LOGGING = {
|
||||
'style': '{',
|
||||
'datefmt': '%Y-%m-%d %H:%M:%S',
|
||||
},
|
||||
'billing': {
|
||||
'format': '[{asctime}] [{levelname}] [{name}] {message}',
|
||||
'style': '{',
|
||||
'datefmt': '%Y-%m-%d %H:%M:%S',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
@@ -593,6 +600,20 @@ LOGGING = {
|
||||
'backupCount': 10,
|
||||
'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': {
|
||||
'publish_sync': {
|
||||
@@ -610,6 +631,16 @@ LOGGING = {
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'billing': {
|
||||
'handlers': ['console', 'billing_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'payments': {
|
||||
'handlers': ['console', 'payment_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'auth.middleware': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
@@ -626,6 +657,7 @@ LOGGING = {
|
||||
# Celery Results Backend
|
||||
CELERY_RESULT_BACKEND = 'django-db'
|
||||
CELERY_CACHE_BACKEND = 'django-cache'
|
||||
CELERY_RESULT_EXTENDED = True # Store task name, args, kwargs in results
|
||||
|
||||
# Import/Export Settings
|
||||
IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||
@@ -704,6 +736,7 @@ UNFOLD = {
|
||||
{"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": "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)
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{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">
|
||||
<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">
|
||||
{(balance?.credits ?? 0).toLocaleString()}
|
||||
</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 className="p-6">
|
||||
@@ -145,21 +161,32 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU
|
||||
{(balance?.plan_credits_per_month ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Status</h3>
|
||||
<div className="mt-2">
|
||||
<Badge variant="light" className="text-lg">
|
||||
{(balance as any)?.subscription_status || 'No subscription'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info box about credit consumption order */}
|
||||
{balance && ((balance as any)?.bonus_credits ?? 0) > 0 && (
|
||||
<Card className="p-4 bg-info-50 dark:bg-info-900/20 border-info-200 dark:border-info-800">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="text-info-600 dark:text-info-400">
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{usageLimits && (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Plan Limits</h2>
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../icons';
|
||||
import { API_BASE_URL } from '../../services/api';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { subscribeToPlan, getAvailablePaymentMethods } from '../../services/billing.api';
|
||||
import { subscribeToPlan, getAvailablePaymentMethods, purchaseCredits } from '../../services/billing.api';
|
||||
|
||||
interface BankDetails {
|
||||
bank_name: string;
|
||||
@@ -38,6 +38,8 @@ interface BankDetails {
|
||||
interface Invoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
invoice_type?: 'subscription' | 'credit_package' | 'addon' | 'custom';
|
||||
credit_package_id?: string | number | null;
|
||||
total?: string;
|
||||
total_amount?: string;
|
||||
currency?: string;
|
||||
@@ -126,6 +128,8 @@ export default function PayInvoiceModal({
|
||||
const currency = invoice.currency?.toUpperCase() || 'USD';
|
||||
const planId = invoice.subscription?.plan?.id;
|
||||
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)
|
||||
const isDefaultMethod = (option: PaymentOption): boolean => {
|
||||
@@ -181,6 +185,29 @@ export default function PayInvoiceModal({
|
||||
}, [isOpen, isPakistan, selectedOption, userCountry, bankDetails]);
|
||||
|
||||
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
|
||||
const planIdentifier = planSlug || (planId ? String(planId) : null);
|
||||
|
||||
@@ -208,6 +235,29 @@ export default function PayInvoiceModal({
|
||||
};
|
||||
|
||||
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
|
||||
const planIdentifier = planSlug || (planId ? String(planId) : null);
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ export default function SiteDashboard() {
|
||||
/>
|
||||
|
||||
<CreditAvailabilityWidget
|
||||
availableCredits={balance?.credits_remaining ?? 0}
|
||||
availableCredits={(balance as any)?.total_credits ?? balance?.credits_remaining ?? 0}
|
||||
totalCredits={balance?.plan_credits_per_month ?? 0}
|
||||
usedCredits={balance?.credits_used_this_month ?? 0}
|
||||
loading={loading}
|
||||
|
||||
@@ -921,16 +921,26 @@ export default function PlansAndBillingPage() {
|
||||
</div>
|
||||
|
||||
{/* 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="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" />
|
||||
Credits
|
||||
Plan Credits
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-brand-900 dark:text-white">
|
||||
{creditBalance?.credits?.toLocaleString() || 0}
|
||||
</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 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">
|
||||
@@ -972,6 +982,21 @@ export default function PlansAndBillingPage() {
|
||||
</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 */}
|
||||
<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">
|
||||
@@ -1319,17 +1344,22 @@ export default function PlansAndBillingPage() {
|
||||
)}
|
||||
</td>
|
||||
<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}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-end">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{invoice.status === 'pending' && (
|
||||
{['pending', 'overdue', 'failed', 'sent'].includes(invoice.status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
tone={invoice.status === 'overdue' || invoice.status === 'failed' ? 'error' : 'brand'}
|
||||
startIcon={<DollarLineIcon className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
setSelectedInvoice(invoice);
|
||||
|
||||
@@ -469,27 +469,49 @@ export default function UsageDashboardPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<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}
|
||||
</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 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}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600 dark:text-purple-300">Used This Month</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}
|
||||
</div>
|
||||
<div className="text-sm text-indigo-600 dark:text-indigo-300">Monthly Allowance</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 */}
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
|
||||
@@ -2087,7 +2087,9 @@ export async function fetchGlobalModuleSettings(): Promise<GlobalModuleSettings>
|
||||
|
||||
// Billing API functions
|
||||
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;
|
||||
credits_used_this_month: number;
|
||||
credits_remaining: number;
|
||||
@@ -2143,6 +2145,8 @@ export async function fetchCreditBalance(): Promise<CreditBalance> {
|
||||
// Default if response is invalid
|
||||
return {
|
||||
credits: 0,
|
||||
bonus_credits: 0,
|
||||
total_credits: 0,
|
||||
plan_credits_per_month: 0,
|
||||
credits_used_this_month: 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 {
|
||||
credits: 0,
|
||||
bonus_credits: 0,
|
||||
total_credits: 0,
|
||||
plan_credits_per_month: 0,
|
||||
credits_used_this_month: 0,
|
||||
credits_remaining: 0,
|
||||
|
||||
Reference in New Issue
Block a user