payemnt billing and credits refactoring
This commit is contained in:
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