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

@@ -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', '')