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:
IGNY8 VPS (Salman)
2025-12-09 06:14:44 +00:00
parent 72d0b6b0fd
commit 4d13a57068
36 changed files with 4159 additions and 253 deletions

View 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}")

View File

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