payment gateways and plans billing and signup pages refactored

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-07 13:02:53 +00:00
parent ad1756c349
commit ad75fa031e
17 changed files with 4587 additions and 500 deletions

View File

@@ -121,55 +121,9 @@ class RegisterView(APIView):
}
}
# For Stripe payment method with paid plan, create checkout session
payment_method = request.data.get('payment_method', '')
import logging
logger = logging.getLogger(__name__)
logger.info(f"Registration: payment_method={payment_method}, account_status={account.status if account else 'no account'}")
if account and account.status == 'pending_payment' and payment_method == 'stripe':
try:
from igny8_core.business.billing.services.stripe_service import StripeService
stripe_service = StripeService()
logger.info(f"Creating Stripe checkout for account {account.id}, plan {account.plan.name}")
checkout_data = stripe_service.create_checkout_session(
account=account,
plan=account.plan,
)
logger.info(f"Stripe checkout created: {checkout_data}")
response_data['checkout_url'] = checkout_data.get('checkout_url')
response_data['checkout_session_id'] = checkout_data.get('session_id')
except Exception as e:
logger.error(f"Failed to create Stripe checkout session: {e}", exc_info=True)
# Don't fail registration, just log the error
# User can still complete payment from the plans page
# For PayPal payment method with paid plan, create PayPal order
elif account and account.status == 'pending_payment' and payment_method == 'paypal':
try:
from django.conf import settings
from igny8_core.business.billing.services.paypal_service import PayPalService
paypal_service = PayPalService()
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
logger.info(f"Creating PayPal order for account {account.id}, amount {account.plan.price}")
order = paypal_service.create_order(
account=account,
amount=float(account.plan.price),
description=f'{account.plan.name} Plan Subscription',
return_url=f'{frontend_url}/account/plans?paypal=success&plan_id={account.plan.id}',
cancel_url=f'{frontend_url}/account/plans?paypal=cancel',
metadata={
'plan_id': str(account.plan.id),
'type': 'subscription',
}
)
logger.info(f"PayPal order created: {order}")
response_data['checkout_url'] = order.get('approval_url')
response_data['paypal_order_id'] = order.get('order_id')
except Exception as e:
logger.error(f"Failed to create PayPal order: {e}", exc_info=True)
# Don't fail registration, just log the error
# NOTE: Payment checkout is NO LONGER created at registration
# User will complete payment on /account/plans after signup
# This simplifies the signup flow and consolidates all payment handling
return success_response(
data=response_data,
@@ -432,6 +386,77 @@ class RefreshTokenView(APIView):
)
@extend_schema(
tags=['Authentication'],
summary='Get Country List',
description='Returns list of countries for registration country selection'
)
class CountryListView(APIView):
"""Returns list of countries for signup dropdown"""
permission_classes = [permissions.AllowAny] # Public endpoint
def get(self, request):
"""Get list of countries with codes and names"""
# Comprehensive list of countries for billing purposes
countries = [
{'code': 'US', 'name': 'United States'},
{'code': 'GB', 'name': 'United Kingdom'},
{'code': 'CA', 'name': 'Canada'},
{'code': 'AU', 'name': 'Australia'},
{'code': 'DE', 'name': 'Germany'},
{'code': 'FR', 'name': 'France'},
{'code': 'ES', 'name': 'Spain'},
{'code': 'IT', 'name': 'Italy'},
{'code': 'NL', 'name': 'Netherlands'},
{'code': 'BE', 'name': 'Belgium'},
{'code': 'CH', 'name': 'Switzerland'},
{'code': 'AT', 'name': 'Austria'},
{'code': 'SE', 'name': 'Sweden'},
{'code': 'NO', 'name': 'Norway'},
{'code': 'DK', 'name': 'Denmark'},
{'code': 'FI', 'name': 'Finland'},
{'code': 'IE', 'name': 'Ireland'},
{'code': 'PT', 'name': 'Portugal'},
{'code': 'PL', 'name': 'Poland'},
{'code': 'CZ', 'name': 'Czech Republic'},
{'code': 'NZ', 'name': 'New Zealand'},
{'code': 'SG', 'name': 'Singapore'},
{'code': 'HK', 'name': 'Hong Kong'},
{'code': 'JP', 'name': 'Japan'},
{'code': 'KR', 'name': 'South Korea'},
{'code': 'IN', 'name': 'India'},
{'code': 'PK', 'name': 'Pakistan'},
{'code': 'BD', 'name': 'Bangladesh'},
{'code': 'AE', 'name': 'United Arab Emirates'},
{'code': 'SA', 'name': 'Saudi Arabia'},
{'code': 'ZA', 'name': 'South Africa'},
{'code': 'NG', 'name': 'Nigeria'},
{'code': 'EG', 'name': 'Egypt'},
{'code': 'KE', 'name': 'Kenya'},
{'code': 'BR', 'name': 'Brazil'},
{'code': 'MX', 'name': 'Mexico'},
{'code': 'AR', 'name': 'Argentina'},
{'code': 'CL', 'name': 'Chile'},
{'code': 'CO', 'name': 'Colombia'},
{'code': 'PE', 'name': 'Peru'},
{'code': 'MY', 'name': 'Malaysia'},
{'code': 'TH', 'name': 'Thailand'},
{'code': 'VN', 'name': 'Vietnam'},
{'code': 'PH', 'name': 'Philippines'},
{'code': 'ID', 'name': 'Indonesia'},
{'code': 'TR', 'name': 'Turkey'},
{'code': 'RU', 'name': 'Russia'},
{'code': 'UA', 'name': 'Ukraine'},
{'code': 'RO', 'name': 'Romania'},
{'code': 'GR', 'name': 'Greece'},
{'code': 'IL', 'name': 'Israel'},
{'code': 'TW', 'name': 'Taiwan'},
]
# Sort alphabetically by name
countries.sort(key=lambda x: x['name'])
return Response({'countries': countries})
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
class MeView(APIView):
"""Get current user information."""
@@ -456,5 +481,6 @@ urlpatterns = [
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
path('me/', MeView.as_view(), name='auth-me'),
path('countries/', CountryListView.as_view(), name='auth-countries'),
]

View File

@@ -487,6 +487,7 @@ class Payment(AccountBaseModel):
manual_reference = models.CharField(
max_length=255,
blank=True,
null=True,
help_text="Bank transfer reference, wallet transaction ID, etc."
)
manual_notes = models.TextField(blank=True, help_text="Admin notes for manual payments")
@@ -526,9 +527,24 @@ class Payment(AccountBaseModel):
models.Index(fields=['account', 'payment_method']),
models.Index(fields=['invoice', 'status']),
]
constraints = [
# Ensure manual_reference is unique when not null/empty
# This prevents duplicate bank transfer references
models.UniqueConstraint(
fields=['manual_reference'],
name='unique_manual_reference_when_not_null',
condition=models.Q(manual_reference__isnull=False) & ~models.Q(manual_reference='')
),
]
def __str__(self):
return f"Payment {self.id} - {self.get_payment_method_display()} - {self.amount} {self.currency}"
def save(self, *args, **kwargs):
"""Normalize empty manual_reference to NULL for proper uniqueness handling"""
if self.manual_reference == '':
self.manual_reference = None
super().save(*args, **kwargs)
class CreditPackage(models.Model):
@@ -854,3 +870,115 @@ class AIModelConfig(models.Model):
model_type='image',
is_active=True
).order_by('quality_tier', 'model_name')
class WebhookEvent(models.Model):
"""
Store all incoming webhook events for audit and replay capability.
This model provides:
- Audit trail of all webhook events
- Idempotency verification (via event_id)
- Ability to replay failed events
- Debugging and monitoring
"""
PROVIDER_CHOICES = [
('stripe', 'Stripe'),
('paypal', 'PayPal'),
]
# Unique identifier from the payment provider
event_id = models.CharField(
max_length=255,
unique=True,
db_index=True,
help_text="Unique event ID from the payment provider"
)
# Payment provider
provider = models.CharField(
max_length=20,
choices=PROVIDER_CHOICES,
db_index=True,
help_text="Payment provider (stripe or paypal)"
)
# Event type (e.g., 'checkout.session.completed', 'PAYMENT.CAPTURE.COMPLETED')
event_type = models.CharField(
max_length=100,
db_index=True,
help_text="Event type from the provider"
)
# Full payload for debugging and replay
payload = models.JSONField(
help_text="Full webhook payload"
)
# Processing status
processed = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this event has been successfully processed"
)
processed_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the event was processed"
)
# Error tracking
error_message = models.TextField(
blank=True,
help_text="Error message if processing failed"
)
retry_count = models.IntegerField(
default=0,
help_text="Number of processing attempts"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_webhook_events'
verbose_name = 'Webhook Event'
verbose_name_plural = 'Webhook Events'
ordering = ['-created_at']
indexes = [
models.Index(fields=['provider', 'event_type']),
models.Index(fields=['processed', 'created_at']),
models.Index(fields=['provider', 'processed']),
]
def __str__(self):
return f"{self.provider}:{self.event_type} - {self.event_id[:20]}..."
@classmethod
def record_event(cls, event_id: str, provider: str, event_type: str, payload: dict):
"""
Record a webhook event. Returns (event, created) tuple.
If the event already exists, returns the existing event.
"""
return cls.objects.get_or_create(
event_id=event_id,
defaults={
'provider': provider,
'event_type': event_type,
'payload': payload,
}
)
def mark_processed(self):
"""Mark the event as successfully processed"""
from django.utils import timezone
self.processed = True
self.processed_at = timezone.now()
self.save(update_fields=['processed', 'processed_at'])
def mark_failed(self, error_message: str):
"""Mark the event as failed with error message"""
self.error_message = error_message
self.retry_count += 1
self.save(update_fields=['error_message', 'retry_count'])

View File

@@ -19,6 +19,7 @@ Endpoints:
"""
import json
import logging
from decimal import Decimal
from django.conf import settings
from django.utils import timezone
from django.db import transaction
@@ -293,6 +294,23 @@ class PayPalCaptureOrderView(APIView):
request=request
)
# IDEMPOTENCY CHECK - Prevent duplicate captures
existing = Payment.objects.filter(
paypal_order_id=order_id,
status='succeeded'
).first()
if existing:
logger.info(f"PayPal order {order_id} already captured as payment {existing.id}")
return success_response(
data={
'status': 'already_captured',
'payment_id': str(existing.id),
'message': 'This order has already been captured'
},
message='Order already captured',
request=request
)
try:
service = PayPalService()
@@ -501,9 +519,8 @@ def paypal_webhook(request):
is_valid = service.verify_webhook_signature(headers, body)
if not is_valid:
logger.warning("PayPal webhook signature verification failed")
# Optionally reject invalid signatures
# return Response({'error': 'Invalid signature'}, status=400)
logger.error("PayPal webhook signature verification failed")
return Response({'error': 'Invalid signature'}, status=status.HTTP_400_BAD_REQUEST)
except PayPalConfigurationError:
logger.warning("PayPal not configured for webhook verification")
@@ -550,6 +567,21 @@ def _process_credit_purchase(account, package_id: str, capture_result: dict) ->
logger.error(f"Credit package {package_id} not found for PayPal capture")
return {'error': 'Package not found'}
# AMOUNT VALIDATION - Prevent price manipulation
captured_amount = Decimal(str(capture_result.get('amount', '0')))
expected_amount = Decimal(str(package.price))
if abs(captured_amount - expected_amount) > Decimal('0.01'):
logger.error(
f"PayPal amount mismatch for package {package_id}: "
f"captured={captured_amount}, expected={expected_amount}"
)
return {
'error': 'Payment amount does not match expected amount',
'captured': str(captured_amount),
'expected': str(expected_amount)
}
with transaction.atomic():
# Create invoice
invoice = InvoiceService.create_credit_package_invoice(

View File

@@ -160,20 +160,18 @@ def initiate_refund(request, payment_id):
def _process_stripe_refund(payment: Payment, amount: Decimal, reason: str) -> bool:
"""Process Stripe refund"""
try:
import stripe
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
from igny8_core.business.billing.services.stripe_service import StripeService
stripe_client = get_stripe_client()
stripe_service = StripeService()
refund = stripe_client.Refund.create(
payment_intent=payment.stripe_payment_intent_id,
refund = stripe_service.create_refund(
payment_intent_id=payment.stripe_payment_intent_id,
amount=int(amount * 100), # Convert to cents
reason='requested_by_customer',
metadata={'reason': reason}
)
payment.metadata['stripe_refund_id'] = refund.id
return refund.status == 'succeeded'
payment.metadata['stripe_refund_id'] = refund.get('id')
return refund.get('status') == 'succeeded'
except Exception as e:
logger.exception(f"Stripe refund failed for payment {payment.id}: {str(e)}")
@@ -183,25 +181,19 @@ def _process_stripe_refund(payment: Payment, amount: Decimal, reason: str) -> bo
def _process_paypal_refund(payment: Payment, amount: Decimal, reason: str) -> bool:
"""Process PayPal refund"""
try:
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
from igny8_core.business.billing.services.paypal_service import PayPalService
paypal_client = get_paypal_client()
paypal_service = PayPalService()
refund_request = {
'amount': {
'value': str(amount),
'currency_code': payment.currency
},
'note_to_payer': reason
}
refund = paypal_client.payments.captures.refund(
payment.paypal_capture_id,
refund_request
refund = paypal_service.refund_capture(
capture_id=payment.paypal_capture_id,
amount=float(amount),
currency=payment.currency,
note=reason,
)
payment.metadata['paypal_refund_id'] = refund.id
return refund.status == 'COMPLETED'
payment.metadata['paypal_refund_id'] = refund.get('id')
return refund.get('status') == 'COMPLETED'
except Exception as e:
logger.exception(f"PayPal refund failed for payment {payment.id}: {str(e)}")

View File

@@ -358,6 +358,15 @@ def _handle_checkout_completed(session: dict):
Processes both subscription and one-time credit purchases.
"""
session_id = session.get('id')
# IDEMPOTENCY CHECK - Prevent processing duplicate webhooks
if Payment.objects.filter(
metadata__stripe_checkout_session_id=session_id
).exists():
logger.info(f"Webhook already processed for session {session_id}")
return
metadata = session.get('metadata', {})
account_id = metadata.get('account_id')
payment_type = metadata.get('type', '')

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.2.9 on 2026-01-07 12:26
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0028_cleanup_payment_method_config'),
('igny8_core_auth', '0020_fix_historical_account'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WebhookEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_id', models.CharField(db_index=True, help_text='Unique event ID from the payment provider', max_length=255, unique=True)),
('provider', models.CharField(choices=[('stripe', 'Stripe'), ('paypal', 'PayPal')], db_index=True, help_text='Payment provider (stripe or paypal)', max_length=20)),
('event_type', models.CharField(db_index=True, help_text='Event type from the provider', max_length=100)),
('payload', models.JSONField(help_text='Full webhook payload')),
('processed', models.BooleanField(db_index=True, default=False, help_text='Whether this event has been successfully processed')),
('processed_at', models.DateTimeField(blank=True, help_text='When the event was processed', null=True)),
('error_message', models.TextField(blank=True, help_text='Error message if processing failed')),
('retry_count', models.IntegerField(default=0, help_text='Number of processing attempts')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Webhook Event',
'verbose_name_plural': 'Webhook Events',
'db_table': 'igny8_webhook_events',
'ordering': ['-created_at'],
},
),
migrations.AlterField(
model_name='historicalpayment',
name='manual_reference',
field=models.CharField(blank=True, help_text='Bank transfer reference, wallet transaction ID, etc.', max_length=255, null=True),
),
migrations.AlterField(
model_name='payment',
name='manual_reference',
field=models.CharField(blank=True, help_text='Bank transfer reference, wallet transaction ID, etc.', max_length=255, null=True),
),
migrations.AddConstraint(
model_name='payment',
constraint=models.UniqueConstraint(condition=models.Q(('manual_reference__isnull', False), models.Q(('manual_reference', ''), _negated=True)), fields=('manual_reference',), name='unique_manual_reference_when_not_null'),
),
migrations.AddIndex(
model_name='webhookevent',
index=models.Index(fields=['provider', 'event_type'], name='igny8_webho_provide_ee8a78_idx'),
),
migrations.AddIndex(
model_name='webhookevent',
index=models.Index(fields=['processed', 'created_at'], name='igny8_webho_process_88c670_idx'),
),
migrations.AddIndex(
model_name='webhookevent',
index=models.Index(fields=['provider', 'processed'], name='igny8_webho_provide_df293b_idx'),
),
]