payemnt billing and credits refactoring

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-20 07:39:51 +00:00
parent a97c72640a
commit bc50b022f1
34 changed files with 3028 additions and 307 deletions

View File

@@ -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:

View File

@@ -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)

View File

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

View File

@@ -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}]")

View File

@@ -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

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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,

View 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,
)

View File

@@ -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")

View File

@@ -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: