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