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

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