feat(billing): add missing payment methods and configurations
- Added migration to include global payment method configurations for Stripe and PayPal (both disabled). - Ensured existing payment methods like bank transfer and manual payment are correctly configured. - Added database constraints and indexes for improved data integrity in billing models. - Introduced foreign key relationship between CreditTransaction and Payment models. - Added webhook configuration fields to PaymentMethodConfig for future payment gateway integrations. - Updated SignUpFormUnified component to handle payment method selection based on user country and plan. - Implemented PaymentHistory component to display user's payment history with status indicators.
This commit is contained in:
178
backend/igny8_core/business/billing/tasks/payment_retry.py
Normal file
178
backend/igny8_core/business/billing/tasks/payment_retry.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Payment retry mechanism for failed payments
|
||||
Implements automatic retry logic with exponential backoff
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from celery import shared_task
|
||||
from igny8_core.business.billing.models import Payment
|
||||
from igny8_core.business.billing.config import MAX_PAYMENT_RETRIES
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name='billing.retry_failed_payment')
|
||||
def retry_failed_payment(payment_id: int):
|
||||
"""
|
||||
Retry a failed payment with exponential backoff
|
||||
|
||||
Args:
|
||||
payment_id: Payment ID to retry
|
||||
"""
|
||||
try:
|
||||
payment = Payment.objects.get(id=payment_id)
|
||||
|
||||
# Only retry failed payments
|
||||
if payment.status != 'failed':
|
||||
logger.info(f"Payment {payment_id} status is {payment.status}, skipping retry")
|
||||
return
|
||||
|
||||
# Check retry count
|
||||
retry_count = payment.metadata.get('retry_count', 0)
|
||||
if retry_count >= MAX_PAYMENT_RETRIES:
|
||||
logger.warning(f"Payment {payment_id} exceeded max retries ({MAX_PAYMENT_RETRIES})")
|
||||
payment.metadata['retry_exhausted'] = True
|
||||
payment.save(update_fields=['metadata'])
|
||||
return
|
||||
|
||||
# Process retry based on payment method
|
||||
if payment.payment_method == 'stripe':
|
||||
success = _retry_stripe_payment(payment)
|
||||
elif payment.payment_method == 'paypal':
|
||||
success = _retry_paypal_payment(payment)
|
||||
else:
|
||||
# Manual payments cannot be automatically retried
|
||||
logger.info(f"Payment {payment_id} is manual, cannot auto-retry")
|
||||
return
|
||||
|
||||
# Update retry count
|
||||
retry_count += 1
|
||||
payment.metadata['retry_count'] = retry_count
|
||||
payment.metadata['last_retry_at'] = timezone.now().isoformat()
|
||||
|
||||
if success:
|
||||
payment.status = 'succeeded'
|
||||
payment.processed_at = timezone.now()
|
||||
payment.failure_reason = ''
|
||||
logger.info(f"Payment {payment_id} retry succeeded")
|
||||
else:
|
||||
# Schedule next retry with exponential backoff
|
||||
if retry_count < MAX_PAYMENT_RETRIES:
|
||||
delay_minutes = 5 * (2 ** retry_count) # 5, 10, 20 minutes
|
||||
retry_failed_payment.apply_async(
|
||||
args=[payment_id],
|
||||
countdown=delay_minutes * 60
|
||||
)
|
||||
payment.metadata['next_retry_at'] = (
|
||||
timezone.now() + timedelta(minutes=delay_minutes)
|
||||
).isoformat()
|
||||
logger.info(f"Payment {payment_id} retry {retry_count} failed, next retry in {delay_minutes}m")
|
||||
|
||||
payment.save(update_fields=['status', 'processed_at', 'failure_reason', 'metadata'])
|
||||
|
||||
except Payment.DoesNotExist:
|
||||
logger.error(f"Payment {payment_id} not found for retry")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error retrying payment {payment_id}: {str(e)}")
|
||||
|
||||
|
||||
def _retry_stripe_payment(payment: Payment) -> bool:
|
||||
"""
|
||||
Retry Stripe payment
|
||||
|
||||
Args:
|
||||
payment: Payment instance
|
||||
|
||||
Returns:
|
||||
True if retry succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
import stripe
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
|
||||
|
||||
stripe_client = get_stripe_client()
|
||||
|
||||
# Retrieve payment intent
|
||||
intent = stripe_client.PaymentIntent.retrieve(payment.stripe_payment_intent_id)
|
||||
|
||||
# Attempt to confirm the payment intent
|
||||
if intent.status == 'requires_payment_method':
|
||||
# Cannot retry without new payment method
|
||||
payment.failure_reason = 'Requires new payment method'
|
||||
return False
|
||||
elif intent.status == 'requires_action':
|
||||
# Requires customer action (3D Secure)
|
||||
payment.failure_reason = 'Requires customer authentication'
|
||||
return False
|
||||
elif intent.status == 'succeeded':
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stripe retry error for payment {payment.id}: {str(e)}")
|
||||
payment.failure_reason = str(e)
|
||||
return False
|
||||
|
||||
|
||||
def _retry_paypal_payment(payment: Payment) -> bool:
|
||||
"""
|
||||
Retry PayPal payment
|
||||
|
||||
Args:
|
||||
payment: Payment instance
|
||||
|
||||
Returns:
|
||||
True if retry succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
|
||||
|
||||
paypal_client = get_paypal_client()
|
||||
|
||||
# Check order status
|
||||
order = paypal_client.orders.get(payment.paypal_order_id)
|
||||
|
||||
if order.status == 'APPROVED':
|
||||
# Attempt to capture
|
||||
capture_response = paypal_client.orders.capture(payment.paypal_order_id)
|
||||
if capture_response.status == 'COMPLETED':
|
||||
payment.paypal_capture_id = capture_response.purchase_units[0].payments.captures[0].id
|
||||
return True
|
||||
elif order.status == 'COMPLETED':
|
||||
return True
|
||||
|
||||
payment.failure_reason = f'PayPal order status: {order.status}'
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"PayPal retry error for payment {payment.id}: {str(e)}")
|
||||
payment.failure_reason = str(e)
|
||||
return False
|
||||
|
||||
|
||||
@shared_task(name='billing.schedule_payment_retries')
|
||||
def schedule_payment_retries():
|
||||
"""
|
||||
Periodic task to identify failed payments and schedule retries
|
||||
Should be run every 5 minutes via Celery Beat
|
||||
"""
|
||||
# Get failed payments from last 24 hours that haven't exhausted retries
|
||||
cutoff_time = timezone.now() - timedelta(hours=24)
|
||||
|
||||
failed_payments = Payment.objects.filter(
|
||||
status='failed',
|
||||
failed_at__gte=cutoff_time,
|
||||
payment_method__in=['stripe', 'paypal'] # Only auto-retry gateway payments
|
||||
).exclude(
|
||||
metadata__has_key='retry_exhausted'
|
||||
)
|
||||
|
||||
for payment in failed_payments:
|
||||
retry_count = payment.metadata.get('retry_count', 0)
|
||||
if retry_count < MAX_PAYMENT_RETRIES:
|
||||
# Schedule immediate retry if not already scheduled
|
||||
if 'next_retry_at' not in payment.metadata:
|
||||
retry_failed_payment.delay(payment.id)
|
||||
logger.info(f"Scheduled retry for payment {payment.id}")
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Subscription renewal tasks
|
||||
Handles automatic subscription renewals with Celery
|
||||
"""
|
||||
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.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__)
|
||||
|
||||
|
||||
@shared_task(name='billing.send_renewal_notices')
|
||||
def send_renewal_notices():
|
||||
"""
|
||||
Send renewal notice emails to subscribers
|
||||
Run daily to check subscriptions expiring soon
|
||||
"""
|
||||
notice_date = timezone.now().date() + timedelta(days=SUBSCRIPTION_RENEWAL_NOTICE_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')
|
||||
|
||||
for subscription in subscriptions:
|
||||
# Check if notice already sent
|
||||
if subscription.metadata.get('renewal_notice_sent'):
|
||||
continue
|
||||
|
||||
try:
|
||||
BillingEmailService.send_subscription_renewal_notice(
|
||||
subscription=subscription,
|
||||
days_until_renewal=SUBSCRIPTION_RENEWAL_NOTICE_DAYS
|
||||
)
|
||||
|
||||
# Mark notice as sent
|
||||
subscription.metadata['renewal_notice_sent'] = True
|
||||
subscription.metadata['renewal_notice_sent_at'] = timezone.now().isoformat()
|
||||
subscription.save(update_fields=['metadata'])
|
||||
|
||||
logger.info(f"Renewal notice sent for subscription {subscription.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send renewal notice for subscription {subscription.id}: {str(e)}")
|
||||
|
||||
|
||||
@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
|
||||
"""
|
||||
today = timezone.now().date()
|
||||
|
||||
# Get active subscriptions ending today
|
||||
subscriptions = Subscription.objects.filter(
|
||||
status='active',
|
||||
current_period_end__date=today
|
||||
).select_related('account', 'plan')
|
||||
|
||||
logger.info(f"Processing {subscriptions.count()} subscription renewals for {today}")
|
||||
|
||||
for subscription in subscriptions:
|
||||
try:
|
||||
renew_subscription(subscription.id)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to renew subscription {subscription.id}: {str(e)}")
|
||||
|
||||
|
||||
@shared_task(name='billing.renew_subscription')
|
||||
def renew_subscription(subscription_id: int):
|
||||
"""
|
||||
Renew a specific subscription
|
||||
|
||||
Args:
|
||||
subscription_id: Subscription ID to renew
|
||||
"""
|
||||
try:
|
||||
subscription = Subscription.objects.select_related('account', 'plan').get(id=subscription_id)
|
||||
|
||||
if subscription.status != 'active':
|
||||
logger.warning(f"Subscription {subscription_id} is not active, skipping renewal")
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# Create renewal invoice
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
account=subscription.account,
|
||||
subscription=subscription
|
||||
)
|
||||
|
||||
# Attempt automatic payment if payment method on file
|
||||
payment_attempted = False
|
||||
|
||||
# Check if account has saved payment method
|
||||
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
|
||||
logger.info(f"Manual payment required for subscription {subscription_id}")
|
||||
|
||||
# Mark subscription as pending renewal
|
||||
subscription.status = 'pending_renewal'
|
||||
subscription.metadata['renewal_invoice_id'] = invoice.id
|
||||
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
|
||||
subscription.save(update_fields=['status', 'metadata'])
|
||||
|
||||
# TODO: Send invoice email
|
||||
|
||||
# Clear renewal notice flag
|
||||
if 'renewal_notice_sent' in subscription.metadata:
|
||||
del subscription.metadata['renewal_notice_sent']
|
||||
subscription.save(update_fields=['metadata'])
|
||||
|
||||
except Subscription.DoesNotExist:
|
||||
logger.error(f"Subscription {subscription_id} not found for renewal")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}")
|
||||
|
||||
|
||||
def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool:
|
||||
"""
|
||||
Attempt to charge Stripe subscription
|
||||
|
||||
Returns:
|
||||
True if payment initiated, False otherwise
|
||||
"""
|
||||
try:
|
||||
import stripe
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
|
||||
|
||||
stripe_client = get_stripe_client()
|
||||
|
||||
# Retrieve Stripe subscription
|
||||
stripe_sub = stripe_client.Subscription.retrieve(
|
||||
subscription.metadata['stripe_subscription_id']
|
||||
)
|
||||
|
||||
# Create payment intent for renewal
|
||||
intent = stripe_client.PaymentIntent.create(
|
||||
amount=int(float(invoice.total_amount) * 100),
|
||||
currency=invoice.currency.lower(),
|
||||
customer=stripe_sub.customer,
|
||||
payment_method=stripe_sub.default_payment_method,
|
||||
off_session=True,
|
||||
confirm=True,
|
||||
metadata={
|
||||
'invoice_id': invoice.id,
|
||||
'subscription_id': subscription.id
|
||||
}
|
||||
)
|
||||
|
||||
# Create payment record
|
||||
Payment.objects.create(
|
||||
account=subscription.account,
|
||||
invoice=invoice,
|
||||
amount=invoice.total_amount,
|
||||
currency=invoice.currency,
|
||||
payment_method='stripe',
|
||||
status='processing',
|
||||
stripe_payment_intent_id=intent.id,
|
||||
metadata={'renewal': True}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stripe renewal payment failed for subscription {subscription.id}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def _attempt_paypal_renewal(subscription: Subscription, invoice: Invoice) -> bool:
|
||||
"""
|
||||
Attempt to charge PayPal subscription
|
||||
|
||||
Returns:
|
||||
True if payment initiated, False otherwise
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
|
||||
|
||||
paypal_client = get_paypal_client()
|
||||
|
||||
# PayPal subscriptions bill automatically
|
||||
# We just need to verify the subscription is still active
|
||||
paypal_sub = paypal_client.subscriptions.get(
|
||||
subscription.metadata['paypal_subscription_id']
|
||||
)
|
||||
|
||||
if paypal_sub.status == 'ACTIVE':
|
||||
# PayPal will charge automatically, create payment record as processing
|
||||
Payment.objects.create(
|
||||
account=subscription.account,
|
||||
invoice=invoice,
|
||||
amount=invoice.total_amount,
|
||||
currency=invoice.currency,
|
||||
payment_method='paypal',
|
||||
status='processing',
|
||||
paypal_order_id=subscription.metadata['paypal_subscription_id'],
|
||||
metadata={'renewal': True}
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"PayPal subscription {paypal_sub.id} status: {paypal_sub.status}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"PayPal renewal check failed for subscription {subscription.id}: {str(e)}")
|
||||
return False
|
||||
Reference in New Issue
Block a user