payment gateways and plans billing and signup pages refactored
This commit is contained in:
@@ -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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -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,10 +527,25 @@ 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'])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
1120
docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md
Normal file
1120
docs/90-REFERENCE/PAYMENT-SYSTEM-ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
959
docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md
Normal file
959
docs/90-REFERENCE/PAYMENT-SYSTEM-AUDIT-REPORT.md
Normal file
@@ -0,0 +1,959 @@
|
||||
# Payment System Audit Report
|
||||
|
||||
> **Audit Date:** January 7, 2026
|
||||
> **Status:** Complete (Updated with Frontend UX Audit)
|
||||
> **Severity Levels:** CRITICAL | HIGH | MEDIUM | LOW
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The IGNY8 payment system documentation is **accurate and matches the implementation**. However, the deep audit revealed **45+ issues** across security, reliability, UX, and functionality areas. The most critical concerns involve:
|
||||
|
||||
1. **PayPal webhook signature verification disabled** (security risk)
|
||||
2. **Missing idempotency in payment processing** (double-charge risk)
|
||||
3. **No admin dashboard for manual payment approval** (operational gap)
|
||||
4. **Plan shows "Active" even with unpaid invoice** (misleading UX)
|
||||
5. **Payment options not properly restricted by state** (UX confusion)
|
||||
6. **Hardcoded currency exchange rates** (financial accuracy)
|
||||
7. **Refund functions reference non-existent modules** (broken feature)
|
||||
|
||||
---
|
||||
|
||||
## Documentation Verification
|
||||
|
||||
### All Documented Files: VERIFIED
|
||||
|
||||
| Category | File | Status |
|
||||
|----------|------|--------|
|
||||
| Frontend Entry Points | SignUpFormUnified.tsx | EXISTS |
|
||||
| | PlansAndBillingPage.tsx | EXISTS |
|
||||
| | PayInvoiceModal.tsx | EXISTS |
|
||||
| | PendingPaymentBanner.tsx | EXISTS |
|
||||
| Frontend Services | billing.api.ts | EXISTS |
|
||||
| | authStore.ts | EXISTS |
|
||||
| Backend Views | stripe_views.py | EXISTS |
|
||||
| | paypal_views.py | EXISTS |
|
||||
| Backend Services | stripe_service.py | EXISTS |
|
||||
| | paypal_service.py | EXISTS |
|
||||
| | payment_service.py | EXISTS |
|
||||
| | invoice_service.py | EXISTS |
|
||||
| Models | billing/models.py | EXISTS |
|
||||
| | auth/models.py | EXISTS |
|
||||
|
||||
### Country-Based Payment Logic: CORRECT
|
||||
|
||||
- **Pakistan (PK):** Stripe + Bank Transfer (NO PayPal)
|
||||
- **Global:** Stripe + PayPal
|
||||
|
||||
Logic correctly implemented in:
|
||||
- `SignUpFormUnified.tsx:160-186`
|
||||
- `PayInvoiceModal.tsx:69-97`
|
||||
- `payment_service.py:260-263`
|
||||
|
||||
---
|
||||
|
||||
## PlansAndBillingPage UX Audit (NEW)
|
||||
|
||||
### Overview
|
||||
|
||||
The `/account/plans` page (`PlansAndBillingPage.tsx`) is the central hub for subscription management. This audit identifies **critical UX issues** related to state handling, invoice lifecycle, and payment option restrictions.
|
||||
|
||||
---
|
||||
|
||||
### CRITICAL UX Issue #1: Plan Shows "Active" Even With Unpaid Invoice
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:459-461`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
<Badge variant="solid" tone={hasActivePlan ? 'success' : 'warning'}>
|
||||
{hasActivePlan ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
- `hasActivePlan` is `true` if user has ANY plan assigned (Line 384)
|
||||
- User who signed up for paid plan but never completed payment sees **"Active"**
|
||||
- This is misleading - their account is actually `pending_payment`
|
||||
|
||||
**Correct Logic Should Check:**
|
||||
1. `account.status === 'active'` (not just plan existence)
|
||||
2. No pending invoices
|
||||
3. Subscription status is `active` (not `pending_payment`)
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
const accountStatus = user?.account?.status;
|
||||
const subscriptionStatus = currentSubscription?.status;
|
||||
const isFullyActive = accountStatus === 'active' &&
|
||||
subscriptionStatus === 'active' &&
|
||||
!hasPendingInvoice;
|
||||
|
||||
<Badge variant="solid" tone={isFullyActive ? 'success' : accountStatus === 'pending_payment' ? 'warning' : 'error'}>
|
||||
{isFullyActive ? 'Active' : accountStatus === 'pending_payment' ? 'Pending Payment' : 'Inactive'}
|
||||
</Badge>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CRITICAL UX Issue #2: Subscription States Not Properly Reflected
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:379-386`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
const currentSubscription = subscriptions.find((sub) => sub.status === 'active') || subscriptions[0];
|
||||
// ...
|
||||
const hasActivePlan = Boolean(effectivePlanId);
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
The page doesn't distinguish between subscription statuses:
|
||||
- `active` - Paid and working
|
||||
- `pending_payment` - Waiting for first payment
|
||||
- `past_due` - Renewal payment failed
|
||||
- `canceled` - User cancelled
|
||||
|
||||
**Missing Status Handling:**
|
||||
|
||||
| Subscription Status | What User Sees | What They SHOULD See |
|
||||
|---------------------|----------------|----------------------|
|
||||
| `pending_payment` | "Active" badge | "Payment Required" with prominent CTA |
|
||||
| `past_due` | No indication | "Payment Overdue" warning |
|
||||
| `canceled` | May still show "Active" | "Cancels on [date]" |
|
||||
| `trialing` | "Active" | "Trial (X days left)" |
|
||||
|
||||
**Fix Required:** Add comprehensive status display:
|
||||
```tsx
|
||||
const getSubscriptionDisplay = () => {
|
||||
if (!currentSubscription) return { label: 'No Plan', tone: 'error' };
|
||||
|
||||
switch (currentSubscription.status) {
|
||||
case 'active':
|
||||
return hasPendingInvoice
|
||||
? { label: 'Payment Due', tone: 'warning' }
|
||||
: { label: 'Active', tone: 'success' };
|
||||
case 'pending_payment':
|
||||
return { label: 'Awaiting Payment', tone: 'warning' };
|
||||
case 'past_due':
|
||||
return { label: 'Payment Overdue', tone: 'error' };
|
||||
case 'canceled':
|
||||
return { label: `Cancels ${formatDate(currentSubscription.cancel_at)}`, tone: 'warning' };
|
||||
case 'trialing':
|
||||
return { label: `Trial`, tone: 'info' };
|
||||
default:
|
||||
return { label: currentSubscription.status, tone: 'neutral' };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH UX Issue #3: Upgrade Button Available When Payment Pending
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:478-486`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={() => setShowUpgradeModal(true)}
|
||||
startIcon={<ArrowUpIcon className="w-4 h-4" />}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Problem:** User with pending invoice can click "Upgrade" and attempt to subscribe to another plan, creating confusion and potentially duplicate subscriptions.
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={() => setShowUpgradeModal(true)}
|
||||
disabled={hasPendingInvoice || accountStatus === 'pending_payment'}
|
||||
startIcon={<ArrowUpIcon className="w-4 h-4" />}
|
||||
>
|
||||
{hasPendingInvoice ? 'Pay Invoice First' : 'Upgrade'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH UX Issue #4: Cancel Subscription Available When Account Already Pending
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:609-616`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
{hasActivePlan && (
|
||||
<button onClick={() => setShowCancelConfirm(true)} ...>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Problem:** User with `pending_payment` status can "cancel" a subscription they never paid for. This is confusing.
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
{hasActivePlan && accountStatus === 'active' && !hasPendingInvoice && (
|
||||
<button onClick={() => setShowCancelConfirm(true)} ...>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH UX Issue #5: "Manage Billing" Button Shown to Non-Stripe Users
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:468-477`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
{availableGateways.stripe && hasActivePlan && (
|
||||
<Button ... onClick={handleManageSubscription}>
|
||||
Manage Billing
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
- Shows "Manage Billing" if Stripe is available, even if user pays via Bank Transfer
|
||||
- Bank Transfer users clicking this get error "No Stripe customer ID"
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
{availableGateways.stripe &&
|
||||
hasActivePlan &&
|
||||
user?.account?.stripe_customer_id &&
|
||||
selectedPaymentMethod === 'stripe' && (
|
||||
<Button ... onClick={handleManageSubscription}>
|
||||
Manage Billing
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH UX Issue #6: Credits Section Doesn't Show Pending Credit Purchases
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:689-713`
|
||||
|
||||
**Problem:** If user purchased credits via bank transfer and it's `pending_approval`, they don't see this anywhere clearly. They might try to purchase again.
|
||||
|
||||
**Fix Required:** Add pending credits indicator:
|
||||
```tsx
|
||||
{pendingCreditPayments.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-info-50 border border-info-200 rounded-lg">
|
||||
<p className="text-sm text-info-700">
|
||||
You have {pendingCreditPayments.length} credit purchase(s) pending approval
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM UX Issue #7: Invoice Status Badge Colors Inconsistent
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:817-819`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
<Badge variant="soft" tone={invoice.status === 'paid' ? 'success' : 'warning'}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
```
|
||||
|
||||
**Problem:** Only handles `paid` and everything else is `warning`. Missing:
|
||||
- `draft` - Should be gray/neutral
|
||||
- `void` - Should be gray
|
||||
- `uncollectible` - Should be error/red
|
||||
- `pending` - Warning (correct)
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
const getInvoiceStatusTone = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'success';
|
||||
case 'pending': return 'warning';
|
||||
case 'void':
|
||||
case 'draft': return 'neutral';
|
||||
case 'uncollectible': return 'error';
|
||||
default: return 'neutral';
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM UX Issue #8: No Clear Indication of Payment Method per Invoice
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:809-849` (Invoice table)
|
||||
|
||||
**Problem:** Invoice table doesn't show which payment method was used/expected. User can't tell if they need to do bank transfer or card payment.
|
||||
|
||||
**Fix Required:** Add payment method column:
|
||||
```tsx
|
||||
<td className="px-6 py-3 text-center">
|
||||
{invoice.payment_method === 'bank_transfer' ? (
|
||||
<span className="flex items-center gap-1"><Building2Icon className="w-4 h-4" /> Bank</span>
|
||||
) : invoice.payment_method === 'paypal' ? (
|
||||
<span className="flex items-center gap-1"><WalletIcon className="w-4 h-4" /> PayPal</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1"><CreditCardIcon className="w-4 h-4" /> Card</span>
|
||||
)}
|
||||
</td>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM UX Issue #9: Renewal Date Shows Even When No Active Subscription
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:514-522`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{currentSubscription?.current_period_end
|
||||
? new Date(currentSubscription.current_period_end).toLocaleDateString(...)
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">Next billing</div>
|
||||
```
|
||||
|
||||
**Problem:** Shows "—" and "Next billing" even when:
|
||||
- Account is `pending_payment` (never billed yet)
|
||||
- Subscription is `canceled` (won't renew)
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{currentSubscription?.status === 'canceled' ? 'Ends on' :
|
||||
currentSubscription?.status === 'pending_payment' ? 'Starts after payment' :
|
||||
'Next billing'}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM UX Issue #10: Payment Gateway Selection Not Synced With Account
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:655-686`
|
||||
|
||||
**Problem:** Payment method selector in "Buy Credits" section can show options the user hasn't verified. If user signed up with bank transfer, they shouldn't see PayPal as an option until they've added it.
|
||||
|
||||
**Current Logic:** Shows all `availableGateways` regardless of user's `AccountPaymentMethod` records.
|
||||
|
||||
**Fix Required:**
|
||||
```tsx
|
||||
// Only show gateways user has verified OR is willing to add
|
||||
const userCanUseGateway = (gateway: PaymentGateway) => {
|
||||
const userHasMethod = userPaymentMethods.some(m =>
|
||||
(gateway === 'stripe' && m.type === 'stripe') ||
|
||||
(gateway === 'paypal' && m.type === 'paypal') ||
|
||||
(gateway === 'manual' && ['bank_transfer', 'local_wallet'].includes(m.type))
|
||||
);
|
||||
return availableGateways[gateway] && (userHasMethod || gateway === selectedGateway);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### LOW UX Issue #11: Annual Billing Toggle Does Nothing
|
||||
|
||||
**Location:** `PlansAndBillingPage.tsx:959-983`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
const displayPrice = selectedBillingCycle === 'annual' ? (annualPrice / 12).toFixed(0) : planPrice;
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
- Shows "Save 20%" badge for annual
|
||||
- Calculates display price
|
||||
- But **no annual plans exist** in database
|
||||
- Clicking "Choose Plan" subscribes to monthly regardless
|
||||
|
||||
**Fix Required:** Either:
|
||||
1. Remove annual toggle until annual plans implemented
|
||||
2. Implement annual plan variants in backend
|
||||
3. Pass `billing_cycle` to `subscribeToPlan()` and handle in backend
|
||||
|
||||
---
|
||||
|
||||
### LOW UX Issue #12: PayInvoiceModal Hardcodes Bank Details
|
||||
|
||||
**Location:** `PayInvoiceModal.tsx:437-443`
|
||||
|
||||
**Current Code:**
|
||||
```tsx
|
||||
<p><span className="font-medium">Bank:</span> Standard Chartered Bank Pakistan</p>
|
||||
<p><span className="font-medium">Account Title:</span> IGNY8 Technologies</p>
|
||||
<p><span className="font-medium">Account #:</span> 01-2345678-01</p>
|
||||
```
|
||||
|
||||
**Problem:** Bank details hardcoded in frontend. Should come from `PaymentMethodConfig` in backend.
|
||||
|
||||
**Fix Required:** Fetch bank details from `/v1/billing/payment-configs/payment-methods/?country_code=PK&payment_method=bank_transfer` and display dynamically.
|
||||
|
||||
---
|
||||
|
||||
### Account Lifecycle State Machine (Missing)
|
||||
|
||||
The page doesn't follow a clear state machine. Here's what it SHOULD be:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ACCOUNT STATES │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ Payment ┌──────────┐ Payment ┌──────────┐ │
|
||||
│ │ trial │ ────────────▶ │ pending_ │ ────────────▶ │ active │ │
|
||||
│ │ │ Required │ payment │ Success │ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ │ Payment │ │
|
||||
│ │ │ Failed │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ expired │ │suspended │ ◀─────────── │ past_due │ │
|
||||
│ │ │ │ │ Auto │ │ │
|
||||
│ └──────────┘ └──────────┘ Suspend └──────────┘ │
|
||||
│ │ │ │
|
||||
│ │ Admin │ │
|
||||
│ │ Action │ │
|
||||
│ ▼ │ │
|
||||
│ ┌──────────┐ │ │
|
||||
│ │cancelled │ ◀──────────────────┘ │
|
||||
│ │ │ User Cancel │
|
||||
│ └──────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Each State Should Show:**
|
||||
|
||||
| State | Plan Badge | Actions Available | Warnings |
|
||||
|-------|------------|-------------------|----------|
|
||||
| `trial` | "Trial (X days)" | Upgrade, Buy Credits | "Trial ends [date]" |
|
||||
| `pending_payment` | "Awaiting Payment" | Pay Invoice, Change Method | "Complete payment to activate" |
|
||||
| `active` | "Active" | Upgrade, Buy Credits, Cancel, Manage | None |
|
||||
| `past_due` | "Payment Overdue" | Update Payment, Pay Now | "Update payment to avoid suspension" |
|
||||
| `suspended` | "Suspended" | Pay to Reactivate | "Account suspended - pay to restore" |
|
||||
| `cancelled` | "Cancels [date]" | Resubscribe | "Access ends [date]" |
|
||||
|
||||
---
|
||||
|
||||
### Payment Options By State (Missing Restrictions)
|
||||
|
||||
**Current:** All payment options shown regardless of account state.
|
||||
|
||||
**Required Restrictions:**
|
||||
|
||||
| Account State | Can Upgrade? | Can Buy Credits? | Can Change Plan? | Can Cancel? |
|
||||
|---------------|--------------|------------------|------------------|-------------|
|
||||
| `trial` | Yes | Yes | N/A | No |
|
||||
| `pending_payment` | No (Pay first) | No (Pay first) | No | No |
|
||||
| `active` | Yes | Yes | Yes | Yes |
|
||||
| `past_due` | No (Pay first) | No | No | Yes |
|
||||
| `suspended` | No | No | No | No |
|
||||
| `cancelled` | Yes (Resubscribe) | No | No | No |
|
||||
|
||||
---
|
||||
|
||||
### Summary of PlansAndBillingPage Fixes Needed
|
||||
|
||||
| # | Issue | Severity | Effort |
|
||||
|---|-------|----------|--------|
|
||||
| 1 | Plan shows "Active" with unpaid invoice | CRITICAL | 30 min |
|
||||
| 2 | Subscription states not reflected | CRITICAL | 1 hr |
|
||||
| 3 | Upgrade available when payment pending | HIGH | 15 min |
|
||||
| 4 | Cancel available for unpaid subscriptions | HIGH | 15 min |
|
||||
| 5 | Manage Billing shown to non-Stripe users | HIGH | 15 min |
|
||||
| 6 | No pending credit purchase indicator | HIGH | 30 min |
|
||||
| 7 | Invoice status colors inconsistent | MEDIUM | 15 min |
|
||||
| 8 | No payment method shown per invoice | MEDIUM | 30 min |
|
||||
| 9 | Renewal date context wrong | MEDIUM | 15 min |
|
||||
| 10 | Gateway selection not synced | MEDIUM | 30 min |
|
||||
| 11 | Annual billing does nothing | LOW | 2 hrs |
|
||||
| 12 | Bank details hardcoded | LOW | 1 hr |
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues (Immediate Action Required)
|
||||
|
||||
### 1. PayPal Webhook Signature Not Enforced
|
||||
|
||||
**Location:** `paypal_views.py:498-511`
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
if not is_valid:
|
||||
logger.warning("PayPal webhook signature verification failed")
|
||||
# Optionally reject invalid signatures
|
||||
# return Response({'error': 'Invalid signature'}, status=400) # COMMENTED OUT!
|
||||
```
|
||||
|
||||
**Risk:** Malicious actors can craft fake webhook events to:
|
||||
- Approve payments that never happened
|
||||
- Cancel legitimate subscriptions
|
||||
- Add credits without payment
|
||||
|
||||
**Fix Required:**
|
||||
```python
|
||||
if not is_valid:
|
||||
logger.error("PayPal webhook signature verification failed")
|
||||
return Response({'error': 'Invalid signature'}, status=400) # UNCOMMENT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Stripe Webhook Not Idempotent (Double-Charge Risk)
|
||||
|
||||
**Location:** `stripe_views.py:380-391` (_handle_checkout_completed)
|
||||
|
||||
**Problem:** Webhook can be called multiple times for same event. No check prevents duplicate invoice/payment creation.
|
||||
|
||||
**Scenario:**
|
||||
1. Stripe sends webhook
|
||||
2. Invoice and payment created
|
||||
3. Stripe retries webhook (network timeout)
|
||||
4. **Duplicate invoice and payment created**
|
||||
|
||||
**Fix Required:**
|
||||
```python
|
||||
# At start of _handle_checkout_completed:
|
||||
session_id = session.get('id')
|
||||
if Payment.objects.filter(stripe_checkout_session_id=session_id).exists():
|
||||
logger.info(f"Webhook already processed for session {session_id}")
|
||||
return # Already processed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. PayPal Capture Order Not Idempotent
|
||||
|
||||
**Location:** `paypal_views.py:261-365` (PayPalCaptureOrderView)
|
||||
|
||||
**Problem:** If frontend calls `/capture-order/` twice (network timeout), payment captured twice.
|
||||
|
||||
**Fix Required:**
|
||||
```python
|
||||
# Check if order already captured
|
||||
existing = Payment.objects.filter(paypal_order_id=order_id, status='succeeded').first()
|
||||
if existing:
|
||||
return Response({'status': 'already_captured', 'payment_id': existing.id})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Refund Functions Call Non-Existent Modules
|
||||
|
||||
**Location:** `refund_views.py:160-208`
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client # DOESN'T EXIST
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client # DOESN'T EXIST
|
||||
```
|
||||
|
||||
**Risk:** Refunds marked as processed but **never actually charged back** to customer.
|
||||
|
||||
**Fix Required:** Create the missing modules or use existing service classes:
|
||||
```python
|
||||
from igny8_core.business.billing.services.stripe_service import StripeService
|
||||
from igny8_core.business.billing.services.paypal_service import PayPalService
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Amount Validation Missing for PayPal
|
||||
|
||||
**Location:** `paypal_views.py:569`
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
amount = float(capture_result.get('amount', package.price)) # Trusts PayPal amount
|
||||
```
|
||||
|
||||
**Risk:** If PayPal returns wrong amount, system processes it as correct.
|
||||
|
||||
**Fix Required:**
|
||||
```python
|
||||
captured_amount = float(capture_result.get('amount', 0))
|
||||
expected_amount = float(package.price)
|
||||
if abs(captured_amount - expected_amount) > 0.01: # Allow 1 cent tolerance
|
||||
logger.error(f"Amount mismatch: captured={captured_amount}, expected={expected_amount}")
|
||||
return Response({'error': 'Amount mismatch'}, status=400)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Priority Issues
|
||||
|
||||
### 6. No Admin Dashboard for Pending Payments
|
||||
|
||||
**Problem:** Admins must use Django admin to approve manual payments.
|
||||
|
||||
**Missing Endpoint:**
|
||||
```
|
||||
GET /v1/admin/billing/pending-payments/ - List pending approvals
|
||||
POST /v1/admin/billing/payments/{id}/approve/ - Approve payment
|
||||
POST /v1/admin/billing/payments/{id}/reject/ - Reject payment
|
||||
```
|
||||
|
||||
**Required Implementation:**
|
||||
```python
|
||||
class AdminPaymentViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def pending(self, request):
|
||||
payments = Payment.objects.filter(status='pending_approval')
|
||||
return Response(PaymentSerializer(payments, many=True).data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def approve(self, request, pk=None):
|
||||
payment = self.get_object()
|
||||
PaymentService.approve_manual_payment(payment, request.user.id, request.data.get('notes'))
|
||||
return Response({'status': 'approved'})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Invoice Number Race Condition
|
||||
|
||||
**Location:** `invoice_service.py:52-78`
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
count = Invoice.objects.select_for_update().filter(...).count()
|
||||
invoice_number = f"{prefix}-{count + 1:04d}"
|
||||
while Invoice.objects.filter(invoice_number=invoice_number).exists(): # NOT LOCKED!
|
||||
count += 1
|
||||
```
|
||||
|
||||
**Fix Required:**
|
||||
```python
|
||||
# Use database unique constraint + retry logic
|
||||
@transaction.atomic
|
||||
def generate_invoice_number(account_id, invoice_type='SUB'):
|
||||
prefix = f"INV-{account_id}-{invoice_type}-{timezone.now().strftime('%Y%m')}"
|
||||
for attempt in range(5):
|
||||
count = Invoice.objects.filter(invoice_number__startswith=prefix).count()
|
||||
invoice_number = f"{prefix}-{count + 1:04d}"
|
||||
try:
|
||||
# Use get_or_create with unique constraint
|
||||
return invoice_number
|
||||
except IntegrityError:
|
||||
continue
|
||||
raise ValueError("Unable to generate unique invoice number")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Browser Redirect Lost After Payment
|
||||
|
||||
**Problem:** If user closes browser after Stripe payment but before redirect:
|
||||
- Payment succeeds (webhook processes it)
|
||||
- User doesn't know payment succeeded
|
||||
- May attempt to pay again
|
||||
|
||||
**Fix Required:** Add payment status check endpoint:
|
||||
```python
|
||||
# New endpoint
|
||||
GET /v1/billing/payment-status/{session_id}/
|
||||
|
||||
# Frontend should check this on /account/plans load
|
||||
const checkPaymentStatus = async (sessionId) => {
|
||||
const response = await fetch(`/v1/billing/payment-status/${sessionId}/`);
|
||||
if (response.data.status === 'completed') {
|
||||
toast.success('Your previous payment was successful!');
|
||||
refreshUser();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. Subscription Renewal Gets Stuck
|
||||
|
||||
**Location:** `subscription_renewal.py:78-131`
|
||||
|
||||
**Problem:** Status set to `pending_renewal` with no expiry or retry mechanism.
|
||||
|
||||
**Fix Required:**
|
||||
```python
|
||||
# Add Celery task
|
||||
@app.task
|
||||
def check_stuck_renewals():
|
||||
"""Run daily to check for stuck renewals"""
|
||||
stuck = Subscription.objects.filter(
|
||||
status='pending_renewal',
|
||||
metadata__renewal_required_at__lt=timezone.now() - timedelta(days=7)
|
||||
)
|
||||
for sub in stuck:
|
||||
# Send reminder email
|
||||
send_renewal_reminder(sub)
|
||||
# After 14 days, suspend
|
||||
if sub.metadata.get('renewal_required_at') < timezone.now() - timedelta(days=14):
|
||||
sub.status = 'past_due'
|
||||
sub.account.status = 'suspended'
|
||||
sub.save()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. Currency Exchange Rates Hardcoded
|
||||
|
||||
**Location:** `currency.py:137-157`
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
CURRENCY_MULTIPLIERS = {
|
||||
'PKR': 278.0, # STATIC - real rate changes daily!
|
||||
'INR': 83.0,
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
**Risk:** Users charged incorrect amounts over time.
|
||||
|
||||
**Fix Required:**
|
||||
```python
|
||||
class ExchangeRateService:
|
||||
CACHE_KEY = 'exchange_rates'
|
||||
CACHE_TTL = 86400 # 24 hours
|
||||
|
||||
@classmethod
|
||||
def get_rate(cls, currency):
|
||||
rates = cache.get(cls.CACHE_KEY)
|
||||
if not rates:
|
||||
rates = cls._fetch_from_api()
|
||||
cache.set(cls.CACHE_KEY, rates, cls.CACHE_TTL)
|
||||
return rates.get(currency, 1.0)
|
||||
|
||||
@classmethod
|
||||
def _fetch_from_api(cls):
|
||||
# Use OpenExchangeRates, Fixer.io, or similar
|
||||
response = requests.get('https://api.exchangerate-api.com/v4/latest/USD')
|
||||
return response.json()['rates']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Medium Priority Issues
|
||||
|
||||
### 11. No Promo Code/Discount Support
|
||||
|
||||
**Missing Models:**
|
||||
```python
|
||||
class PromoCode(models.Model):
|
||||
code = models.CharField(max_length=50, unique=True)
|
||||
discount_type = models.CharField(choices=[('percent', '%'), ('fixed', '$')])
|
||||
discount_value = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
valid_from = models.DateTimeField()
|
||||
valid_until = models.DateTimeField(null=True)
|
||||
max_uses = models.IntegerField(null=True)
|
||||
current_uses = models.IntegerField(default=0)
|
||||
applicable_plans = models.ManyToManyField('Plan', blank=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. No Partial Payment Support
|
||||
|
||||
**Current:** User must pay full invoice amount.
|
||||
|
||||
**Needed:**
|
||||
- Split invoice into multiple payments
|
||||
- Track partial payment progress
|
||||
- Handle remaining balance
|
||||
|
||||
---
|
||||
|
||||
### 13. No Dunning Management
|
||||
|
||||
**Missing:** When payment fails:
|
||||
- Day 1: Payment failed notification
|
||||
- Day 3: Retry attempt + reminder
|
||||
- Day 7: Second retry + warning
|
||||
- Day 14: Account suspension warning
|
||||
- Day 21: Account suspended
|
||||
|
||||
---
|
||||
|
||||
### 14. No Manual Payment Reference Uniqueness
|
||||
|
||||
**Location:** `models.py:487-490`
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
manual_reference = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
unique=True, # ADD THIS
|
||||
null=True # Allow null for non-manual payments
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 15. Refund Credit Deduction Race Condition
|
||||
|
||||
**Location:** `refund_views.py:108-129`
|
||||
|
||||
**Fix:** Use `select_for_update()`:
|
||||
```python
|
||||
with transaction.atomic():
|
||||
account = Account.objects.select_for_update().get(id=payment.account_id)
|
||||
if account.credit_balance >= credits_to_deduct:
|
||||
account.credit_balance -= credits_to_deduct
|
||||
account.save()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 16. Invoice Total Calculation Silent Failure
|
||||
|
||||
**Location:** `models.py:439-448`
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
except Exception:
|
||||
pass # SILENT FAILURE!
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid line item in invoice {self.id}: {item}, error: {e}")
|
||||
raise ValueError(f"Invalid invoice line item: {item}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 17. No Webhook Event Storage
|
||||
|
||||
**Missing:** All incoming webhooks should be stored for:
|
||||
- Audit trail
|
||||
- Replay on failure
|
||||
- Debugging
|
||||
|
||||
**Add Model:**
|
||||
```python
|
||||
class WebhookEvent(models.Model):
|
||||
event_id = models.CharField(max_length=255, unique=True)
|
||||
provider = models.CharField(max_length=20) # stripe, paypal
|
||||
event_type = models.CharField(max_length=100)
|
||||
payload = models.JSONField()
|
||||
processed = models.BooleanField(default=False)
|
||||
processed_at = models.DateTimeField(null=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 18. No Payment Audit Trail for Rejections
|
||||
|
||||
**Missing Fields in Payment model:**
|
||||
```python
|
||||
rejected_by = models.ForeignKey('User', null=True, related_name='rejected_payments')
|
||||
rejected_at = models.DateTimeField(null=True)
|
||||
rejection_reason = models.TextField(blank=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Immediate (This Week)
|
||||
|
||||
| # | Issue | Effort | Impact |
|
||||
|---|-------|--------|--------|
|
||||
| 1 | Enable PayPal webhook signature verification | 5 min | CRITICAL |
|
||||
| 2 | Add Stripe webhook idempotency check | 30 min | CRITICAL |
|
||||
| 3 | Add PayPal capture idempotency check | 30 min | CRITICAL |
|
||||
| 4 | Fix refund module imports | 1 hr | CRITICAL |
|
||||
| 5 | Add PayPal amount validation | 30 min | CRITICAL |
|
||||
|
||||
### Short-Term (This Month)
|
||||
|
||||
| # | Issue | Effort | Impact |
|
||||
|---|-------|--------|--------|
|
||||
| 6 | Build admin pending payments dashboard | 1 day | HIGH |
|
||||
| 7 | Fix invoice number race condition | 2 hrs | HIGH |
|
||||
| 8 | Add payment status check endpoint | 2 hrs | HIGH |
|
||||
| 9 | Fix stuck renewal subscriptions | 1 day | HIGH |
|
||||
| 10 | Implement dynamic currency rates | 1 day | HIGH |
|
||||
|
||||
### Medium-Term (This Quarter)
|
||||
|
||||
| # | Issue | Effort | Impact |
|
||||
|---|-------|--------|--------|
|
||||
| 11 | Promo code system | 3 days | MEDIUM |
|
||||
| 12 | Partial payment support | 2 days | MEDIUM |
|
||||
| 13 | Dunning management | 2 days | MEDIUM |
|
||||
| 14 | Webhook event storage | 1 day | MEDIUM |
|
||||
| 15 | Payment audit trail | 1 day | MEDIUM |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Assessment
|
||||
|
||||
### What's Good
|
||||
|
||||
1. **Clean separation** between frontend entry points, services, and backend
|
||||
2. **Country-based logic** correctly isolates Pakistan users from PayPal
|
||||
3. **Multi-gateway support** (Stripe, PayPal, Manual) well architected
|
||||
4. **Service layer abstraction** (`StripeService`, `PayPalService`, `PaymentService`)
|
||||
5. **Invoice and payment tracking** comprehensive
|
||||
6. **Webhook handlers** exist for both gateways
|
||||
|
||||
### What Needs Improvement
|
||||
|
||||
1. **Idempotency** - Critical for payment processing
|
||||
2. **Transaction safety** - Need more `@transaction.atomic()` and `select_for_update()`
|
||||
3. **Observability** - No webhook event storage, limited metrics
|
||||
4. **Admin tooling** - Manual payments need proper dashboard
|
||||
5. **Error handling** - Too many silent failures
|
||||
6. **Feature gaps** - No promo codes, partial payments, dunning
|
||||
|
||||
---
|
||||
|
||||
## Final Assessment
|
||||
|
||||
| Area | Rating | Notes |
|
||||
|------|--------|-------|
|
||||
| Documentation Accuracy | A | Matches codebase |
|
||||
| Security | C | Webhook verification gaps |
|
||||
| Reliability | C | Idempotency issues |
|
||||
| Completeness | B | Core features present |
|
||||
| Admin Experience | D | No proper dashboard |
|
||||
| User Experience | B | Good flows, missing status checks |
|
||||
| Code Quality | B | Good structure, some silent failures |
|
||||
|
||||
**Overall Grade: C+**
|
||||
|
||||
The payment system is functional but has critical security and reliability gaps that must be addressed before scaling. The architecture is sound, but implementation details need hardening.
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins (Can Do Today)
|
||||
|
||||
1. Uncomment PayPal webhook signature rejection (5 min)
|
||||
2. Add `@transaction.atomic()` to all payment handlers (30 min)
|
||||
3. Add duplicate check before creating payments (30 min)
|
||||
4. Add unique constraint to `manual_reference` (migration)
|
||||
5. Remove silent `except: pass` blocks (30 min)
|
||||
|
||||
---
|
||||
|
||||
*Report generated by deep audit of IGNY8 payment system codebase.*
|
||||
1350
docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md
Normal file
1350
docs/90-REFERENCE/PAYMENT-SYSTEM-REFACTOR-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ import SuspenseLoader from "./components/common/SuspenseLoader";
|
||||
// Auth pages - loaded immediately (needed for login)
|
||||
import SignIn from "./pages/AuthPages/SignIn";
|
||||
import SignUp from "./pages/AuthPages/SignUp";
|
||||
import SignUpPK from "./pages/AuthPages/SignUpPK";
|
||||
// NOTE: SignUpPK removed - country selection now via dropdown in main signup form
|
||||
import Payment from "./pages/Payment";
|
||||
import NotFound from "./pages/OtherPage/NotFound";
|
||||
|
||||
@@ -139,7 +139,9 @@ export default function App() {
|
||||
{/* Auth Routes - Public */}
|
||||
<Route path="/signin" element={<SignIn />} />
|
||||
<Route path="/signup" element={<SignUp />} />
|
||||
<Route path="/signup/pk" element={<SignUpPK />} />
|
||||
{/* NOTE: /signup/pk removed - country selection now via dropdown in signup form */}
|
||||
{/* Redirect old PK route to main signup */}
|
||||
<Route path="/signup/pk" element={<SignUp />} />
|
||||
<Route path="/payment" element={<Payment />} />
|
||||
|
||||
{/* Legal Pages - Public */}
|
||||
|
||||
@@ -2,29 +2,24 @@
|
||||
* Unified Signup Form with Integrated Pricing Selection
|
||||
* Combines free and paid signup flows in one modern interface
|
||||
*
|
||||
* Payment Methods:
|
||||
* - Most countries: Credit/Debit Card (Stripe) + PayPal
|
||||
* - Pakistan (PK): Credit/Debit Card (Stripe) + Bank Transfer
|
||||
* Payment Flow (Simplified):
|
||||
* 1. User selects plan and fills in details
|
||||
* 2. User selects country from dropdown
|
||||
* 3. On submit: account created, redirected to /account/plans for payment
|
||||
*
|
||||
* NO payment method selection at signup - this happens on /account/plans
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon, CreditCardIcon, Building2Icon, WalletIcon, CheckIcon, Loader2Icon, CheckCircleIcon } from '../../icons';
|
||||
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon, CheckIcon, Loader2Icon, CheckCircleIcon, GlobeIcon } from '../../icons';
|
||||
import Label from '../form/Label';
|
||||
import Input from '../form/input/InputField';
|
||||
import Checkbox from '../form/input/Checkbox';
|
||||
import Button from '../ui/button/Button';
|
||||
import SelectDropdown from '../form/SelectDropdown';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
// PayPal icon component
|
||||
const PayPalIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.607-.541c-.013.076-.026.175-.041.254-.93 4.778-4.005 7.201-9.138 7.201h-2.19a.563.563 0 0 0-.556.479l-1.187 7.527h-.506l-.24 1.516a.56.56 0 0 0 .554.647h3.882c.46 0 .85-.334.922-.788.06-.26.76-4.852.816-5.09a.932.932 0 0 1 .923-.788h.58c3.76 0 6.705-1.528 7.565-5.946.36-1.847.174-3.388-.777-4.471z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface Plan {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -40,22 +35,9 @@ interface Plan {
|
||||
features: string[];
|
||||
}
|
||||
|
||||
interface PaymentMethodConfig {
|
||||
id: number;
|
||||
payment_method: string;
|
||||
display_name: string;
|
||||
instructions: string | null;
|
||||
country_code: string;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
// Payment method option type
|
||||
interface PaymentOption {
|
||||
id: string;
|
||||
type: string;
|
||||
interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface SignUpFormUnifiedProps {
|
||||
@@ -63,7 +45,6 @@ interface SignUpFormUnifiedProps {
|
||||
selectedPlan: Plan | null;
|
||||
onPlanSelect: (plan: Plan) => void;
|
||||
plansLoading: boolean;
|
||||
countryCode?: string; // Optional: 'PK' for Pakistan-specific, empty for global
|
||||
}
|
||||
|
||||
export default function SignUpFormUnified({
|
||||
@@ -71,7 +52,6 @@ export default function SignUpFormUnified({
|
||||
selectedPlan,
|
||||
onPlanSelect,
|
||||
plansLoading,
|
||||
countryCode = '', // Default to global (empty = show Credit Card + PayPal)
|
||||
}: SignUpFormUnifiedProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
@@ -83,13 +63,12 @@ export default function SignUpFormUnified({
|
||||
email: '',
|
||||
password: '',
|
||||
accountName: '',
|
||||
billingCountry: countryCode || 'US',
|
||||
billingCountry: 'US',
|
||||
});
|
||||
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('stripe');
|
||||
const [availablePaymentOptions, setAvailablePaymentOptions] = useState<PaymentOption[]>([]);
|
||||
const [backendPaymentMethods, setBackendPaymentMethods] = useState<PaymentMethodConfig[]>([]);
|
||||
const [paymentMethodsLoading, setPaymentMethodsLoading] = useState(false);
|
||||
// Countries for dropdown
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [countriesLoading, setCountriesLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -97,9 +76,6 @@ export default function SignUpFormUnified({
|
||||
|
||||
const isPaidPlan = selectedPlan && parseFloat(String(selectedPlan.price || 0)) > 0;
|
||||
|
||||
// Determine if this is a Pakistan-specific signup
|
||||
const isPakistanSignup = countryCode === 'PK';
|
||||
|
||||
// Update URL when plan changes
|
||||
useEffect(() => {
|
||||
if (selectedPlan) {
|
||||
@@ -109,108 +85,61 @@ export default function SignUpFormUnified({
|
||||
}
|
||||
}, [selectedPlan]);
|
||||
|
||||
// Load payment methods from backend and determine available options
|
||||
// Load countries from backend and detect user's country
|
||||
useEffect(() => {
|
||||
if (!isPaidPlan) {
|
||||
setAvailablePaymentOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPaymentMethods = async () => {
|
||||
setPaymentMethodsLoading(true);
|
||||
const loadCountriesAndDetect = async () => {
|
||||
setCountriesLoading(true);
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
||||
const response = await fetch(`${API_BASE_URL}/v1/billing/payment-configs/payment-methods/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load payment methods');
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}/v1/auth/countries/`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
let methodsList: PaymentMethodConfig[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
methodsList = data;
|
||||
} else if (data.success && data.data) {
|
||||
methodsList = Array.isArray(data.data) ? data.data : data.data.results || [];
|
||||
} else if (data.results) {
|
||||
methodsList = data.results;
|
||||
}
|
||||
|
||||
const enabledMethods = methodsList.filter((m: PaymentMethodConfig) => m.is_enabled);
|
||||
setBackendPaymentMethods(enabledMethods);
|
||||
|
||||
// Build payment options based on signup type (PK vs Global)
|
||||
const options: PaymentOption[] = [];
|
||||
|
||||
// Always show Credit/Debit Card (Stripe) if enabled
|
||||
const stripeEnabled = enabledMethods.some(m => m.payment_method === 'stripe');
|
||||
if (stripeEnabled) {
|
||||
options.push({
|
||||
id: 'stripe',
|
||||
type: 'stripe',
|
||||
name: 'Credit/Debit Card',
|
||||
description: 'Pay securely with Visa, Mastercard, or other cards',
|
||||
icon: <CreditCardIcon className="w-6 h-6" />,
|
||||
});
|
||||
}
|
||||
|
||||
// For Pakistan signup (/signup/pk): show Bank Transfer
|
||||
// For Global signup (/signup): show PayPal
|
||||
if (isPakistanSignup) {
|
||||
// Pakistan: show Bank Transfer as 2nd option
|
||||
const bankTransferEnabled = enabledMethods.some(
|
||||
m => m.payment_method === 'bank_transfer' && (!m.country_code || m.country_code === 'PK')
|
||||
);
|
||||
if (bankTransferEnabled) {
|
||||
options.push({
|
||||
id: 'bank_transfer',
|
||||
type: 'bank_transfer',
|
||||
name: 'Bank Transfer',
|
||||
description: 'Pay via bank transfer (PKR)',
|
||||
icon: <Building2Icon className="w-6 h-6" />,
|
||||
});
|
||||
}
|
||||
setCountries(data.countries || []);
|
||||
} else {
|
||||
// Global: show PayPal as 2nd option
|
||||
const paypalEnabled = enabledMethods.some(m => m.payment_method === 'paypal');
|
||||
if (paypalEnabled) {
|
||||
options.push({
|
||||
id: 'paypal',
|
||||
type: 'paypal',
|
||||
name: 'PayPal',
|
||||
description: 'Pay with your PayPal account',
|
||||
icon: <PayPalIcon className="w-6 h-6" />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setAvailablePaymentOptions(options);
|
||||
|
||||
// Set default payment method
|
||||
if (options.length > 0 && !options.find(o => o.type === selectedPaymentMethod)) {
|
||||
setSelectedPaymentMethod(options[0].type);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load payment methods:', err);
|
||||
// Fallback to default options
|
||||
setAvailablePaymentOptions([
|
||||
{
|
||||
id: 'stripe',
|
||||
type: 'stripe',
|
||||
name: 'Credit/Debit Card',
|
||||
description: 'Pay securely with Visa, Mastercard, or other cards',
|
||||
icon: <CreditCardIcon className="w-6 h-6" />,
|
||||
}
|
||||
// Fallback countries if backend fails
|
||||
setCountries([
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'PK', name: 'Pakistan' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
]);
|
||||
}
|
||||
|
||||
// Try to detect user's country for default selection
|
||||
try {
|
||||
const geoResponse = await fetch('https://ipapi.co/country_code/', {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (geoResponse.ok) {
|
||||
const countryCode = await geoResponse.text();
|
||||
if (countryCode && countryCode.length === 2) {
|
||||
setFormData(prev => ({ ...prev, billingCountry: countryCode.trim() }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - keep default US
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load countries:', err);
|
||||
// Fallback countries
|
||||
setCountries([
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'PK', name: 'Pakistan' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
]);
|
||||
setSelectedPaymentMethod('stripe');
|
||||
} finally {
|
||||
setPaymentMethodsLoading(false);
|
||||
setCountriesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPaymentMethods();
|
||||
}, [isPaidPlan, isPakistanSignup]);
|
||||
loadCountriesAndDetect();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
@@ -236,11 +165,6 @@ export default function SignUpFormUnified({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaidPlan && !selectedPaymentMethod) {
|
||||
setError('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const username = formData.email.split('@')[0];
|
||||
|
||||
@@ -252,35 +176,20 @@ export default function SignUpFormUnified({
|
||||
last_name: formData.lastName,
|
||||
account_name: formData.accountName,
|
||||
plan_slug: selectedPlan.slug,
|
||||
billing_country: formData.billingCountry,
|
||||
};
|
||||
|
||||
if (isPaidPlan) {
|
||||
registerPayload.payment_method = selectedPaymentMethod;
|
||||
registerPayload.billing_email = formData.email;
|
||||
registerPayload.billing_country = formData.billingCountry;
|
||||
}
|
||||
|
||||
const user = (await register(registerPayload)) as any;
|
||||
|
||||
// Log full registration response for debugging
|
||||
console.log('Registration response:', {
|
||||
user: user,
|
||||
checkoutUrl: user?.checkout_url,
|
||||
selectedPaymentMethod: selectedPaymentMethod,
|
||||
accountStatus: user?.account?.status
|
||||
accountStatus: user?.account?.status,
|
||||
planSlug: selectedPlan.slug,
|
||||
});
|
||||
|
||||
// CRITICAL: Verify auth state is actually set in Zustand store
|
||||
// Verify auth state is actually set in Zustand store
|
||||
const currentAuthState = useAuthStore.getState();
|
||||
|
||||
console.log('Post-registration auth state check:', {
|
||||
isAuthenticated: currentAuthState.isAuthenticated,
|
||||
hasUser: !!currentAuthState.user,
|
||||
hasToken: !!currentAuthState.token,
|
||||
userData: user,
|
||||
checkoutUrl: user?.checkout_url
|
||||
});
|
||||
|
||||
// If for some reason state wasn't set, force set it again
|
||||
if (!currentAuthState.isAuthenticated || !currentAuthState.user || !currentAuthState.token) {
|
||||
console.error('Auth state not properly set after registration, forcing update...');
|
||||
@@ -306,41 +215,16 @@ export default function SignUpFormUnified({
|
||||
throw new Error('Failed to authenticate after registration. Please try logging in manually.');
|
||||
}
|
||||
|
||||
// Handle payment gateway redirects
|
||||
const checkoutUrl = user?.checkout_url;
|
||||
|
||||
console.log('Payment redirect decision:', {
|
||||
checkoutUrl,
|
||||
selectedPaymentMethod,
|
||||
isPaidPlan,
|
||||
fullUserResponse: user,
|
||||
});
|
||||
|
||||
// For Stripe or PayPal with checkout URL - redirect to payment gateway
|
||||
if (checkoutUrl && (selectedPaymentMethod === 'stripe' || selectedPaymentMethod === 'paypal')) {
|
||||
console.log(`Redirecting to ${selectedPaymentMethod} checkout:`, checkoutUrl);
|
||||
window.location.href = checkoutUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// For bank_transfer ONLY - go to plans page to show payment instructions
|
||||
// This is the expected flow for bank transfer
|
||||
if (selectedPaymentMethod === 'bank_transfer') {
|
||||
console.log('Bank transfer selected, redirecting to plans page for payment confirmation');
|
||||
// Simplified navigation:
|
||||
// - Paid plans: Go to /account/plans to select payment method and complete payment
|
||||
// - Free plans: Go to sites page
|
||||
if (isPaidPlan) {
|
||||
console.log('Paid plan selected, redirecting to /account/plans for payment');
|
||||
navigate('/account/plans', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// If Stripe/PayPal but no checkout URL (error case) - still go to plans page
|
||||
// User can retry payment from there
|
||||
if (isPaidPlan && !checkoutUrl) {
|
||||
console.warn('Paid plan selected but no checkout URL received - going to plans page');
|
||||
navigate('/account/plans', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// For free plans - go to sites page
|
||||
} else {
|
||||
console.log('Free plan selected, redirecting to /sites');
|
||||
navigate('/sites', { replace: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed. Please try again.');
|
||||
}
|
||||
@@ -527,77 +411,35 @@ export default function SignUpFormUnified({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPaidPlan && (
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-4">
|
||||
{/* Payment Method Selection - Card Style */}
|
||||
{/* Country Selection */}
|
||||
<div>
|
||||
<Label className="mb-3">
|
||||
Select Payment Method<span className="text-error-500">*</span>
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<GlobeIcon className="w-4 h-4 text-gray-500" />
|
||||
Country<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
{paymentMethodsLoading ? (
|
||||
<div className="flex items-center justify-center p-6 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<Loader2Icon className="w-5 h-5 animate-spin text-brand-500 mr-2" />
|
||||
<span className="text-sm text-gray-500">Loading payment options...</span>
|
||||
</div>
|
||||
) : availablePaymentOptions.length === 0 ? (
|
||||
<div className="p-4 bg-warning-50 border border-warning-200 rounded-lg text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
|
||||
<p className="text-sm">No payment methods available for your region</p>
|
||||
{countriesLoading ? (
|
||||
<div className="flex items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Loader2Icon className="w-4 h-4 animate-spin text-brand-500 mr-2" />
|
||||
<span className="text-sm text-gray-500">Loading countries...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{availablePaymentOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPaymentMethod(option.type)}
|
||||
className={`relative p-4 rounded-xl border-2 text-left transition-all ${
|
||||
selectedPaymentMethod === option.type
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'
|
||||
}`}
|
||||
<select
|
||||
name="billingCountry"
|
||||
value={formData.billingCountry}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 text-sm text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-800 dark:text-white dark:border-gray-700 focus:ring-2 focus:ring-brand-500 focus:border-transparent appearance-none cursor-pointer"
|
||||
>
|
||||
{selectedPaymentMethod === option.type && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="w-5 h-5 bg-brand-500 rounded-full flex items-center justify-center">
|
||||
<CheckIcon className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${
|
||||
selectedPaymentMethod === option.type
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={`font-semibold text-sm ${
|
||||
selectedPaymentMethod === option.type
|
||||
? 'text-brand-700 dark:text-brand-400'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{option.name}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{countries.map((country) => (
|
||||
<option key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pakistan signup notice */}
|
||||
{isPakistanSignup && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<span>🇵🇰</span> Pakistan - Bank transfer available
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Your country determines available payment methods
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3 pt-2">
|
||||
<Checkbox className="w-5 h-5 mt-0.5" checked={isChecked} onChange={setIsChecked} />
|
||||
@@ -620,7 +462,7 @@ export default function SignUpFormUnified({
|
||||
Creating your account...
|
||||
</span>
|
||||
) : isPaidPlan ? (
|
||||
'Create Account & Continue to Payment'
|
||||
'Create Account'
|
||||
) : (
|
||||
'Start Free Trial'
|
||||
)}
|
||||
|
||||
355
frontend/src/components/billing/BankTransferForm.tsx
Normal file
355
frontend/src/components/billing/BankTransferForm.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* BankTransferForm - Component for submitting bank transfer proof
|
||||
*
|
||||
* Used when user selects bank transfer payment method (primarily for Pakistan)
|
||||
* Shows bank details to transfer to and form to submit payment proof
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Building2Icon,
|
||||
CopyIcon,
|
||||
CheckCircleIcon,
|
||||
Loader2Icon,
|
||||
AlertCircleIcon,
|
||||
FileTextIcon,
|
||||
} from '../../icons';
|
||||
import { Card } from '../ui/card';
|
||||
import Button from '../ui/button/Button';
|
||||
import Label from '../form/Label';
|
||||
import Input from '../form/input/InputField';
|
||||
import TextArea from '../form/input/TextArea';
|
||||
import { useToast } from '../ui/toast/ToastContainer';
|
||||
import {
|
||||
Invoice,
|
||||
submitManualPayment,
|
||||
getAvailablePaymentMethods,
|
||||
} from '../../services/billing.api';
|
||||
|
||||
interface BankDetails {
|
||||
bank_name: string;
|
||||
account_title: string;
|
||||
account_number: string;
|
||||
iban?: string;
|
||||
swift_code?: string;
|
||||
}
|
||||
|
||||
interface BankTransferFormProps {
|
||||
invoice: Invoice;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function BankTransferForm({
|
||||
invoice,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: BankTransferFormProps) {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bankDetailsLoading, setBankDetailsLoading] = useState(true);
|
||||
const [bankDetails, setBankDetails] = useState<BankDetails | null>(null);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
reference: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
// Load bank details from backend
|
||||
useEffect(() => {
|
||||
const loadBankDetails = async () => {
|
||||
setBankDetailsLoading(true);
|
||||
try {
|
||||
const { results } = await getAvailablePaymentMethods();
|
||||
// Find bank_transfer method config
|
||||
const bankMethod = results.find(
|
||||
(m) => m.type === 'bank_transfer' && m.is_enabled
|
||||
);
|
||||
|
||||
// Cast to any to access extended bank_details properties
|
||||
// Backend may return additional fields not in the TypeScript type
|
||||
const details = bankMethod?.bank_details as any;
|
||||
|
||||
if (details) {
|
||||
setBankDetails({
|
||||
bank_name: details.bank_name || 'Bank ABC',
|
||||
account_title: details.account_title || details.account_name || 'IGNY8',
|
||||
account_number: details.account_number || '',
|
||||
iban: details.iban,
|
||||
swift_code: details.swift_code,
|
||||
});
|
||||
} else {
|
||||
// Fallback hardcoded details - should be replaced with backend config
|
||||
setBankDetails({
|
||||
bank_name: 'MCB Bank Limited',
|
||||
account_title: 'IGNY8 Technologies',
|
||||
account_number: '0000123456789',
|
||||
iban: 'PK00MUCB0000000123456789',
|
||||
swift_code: 'MUCBPKKAXXX',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load bank details:', error);
|
||||
// Use fallback
|
||||
setBankDetails({
|
||||
bank_name: 'MCB Bank Limited',
|
||||
account_title: 'IGNY8 Technologies',
|
||||
account_number: '0000123456789',
|
||||
iban: 'PK00MUCB0000000123456789',
|
||||
swift_code: 'MUCBPKKAXXX',
|
||||
});
|
||||
} finally {
|
||||
setBankDetailsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadBankDetails();
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = async (value: string, field: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedField(field);
|
||||
toast?.success?.(`${field} copied to clipboard`);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
} catch {
|
||||
toast?.error?.('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.reference.trim()) {
|
||||
toast?.error?.('Please enter your transaction reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await submitManualPayment({
|
||||
invoice_id: invoice.id,
|
||||
payment_method: 'bank_transfer',
|
||||
amount: invoice.total_amount || invoice.total || '0',
|
||||
currency: invoice.currency,
|
||||
reference: formData.reference.trim(),
|
||||
notes: formData.notes.trim(),
|
||||
});
|
||||
|
||||
toast?.success?.('Payment submitted successfully! We will verify it within 24 hours.');
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
console.error('Payment submission failed:', error);
|
||||
toast?.error?.(error.message || 'Failed to submit payment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (bankDetailsLoading) {
|
||||
return (
|
||||
<Card className="p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2Icon className="w-6 h-6 animate-spin text-brand-500 mr-2" />
|
||||
<span className="text-gray-500">Loading bank details...</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bankDetails) {
|
||||
return (
|
||||
<Card className="p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 p-4 bg-error-50 border border-error-200 rounded-lg dark:bg-error-900/20 dark:border-error-800">
|
||||
<AlertCircleIcon className="w-5 h-5 text-error-600" />
|
||||
<span className="text-error-800 dark:text-error-200">
|
||||
Bank details not available. Please contact support.
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-brand-100 dark:bg-brand-900/30">
|
||||
<Building2Icon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Bank Transfer
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Transfer to the account below and submit your reference
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bank Details */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl mb-6 space-y-3">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Transfer Details</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="flex justify-between items-center p-2 bg-white dark:bg-gray-800 rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Bank</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{bankDetails.bank_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-2 bg-white dark:bg-gray-800 rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Account Title</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{bankDetails.account_title}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(bankDetails.account_title, 'Account title')}
|
||||
className="p-1.5 text-gray-400 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
{copiedField === 'Account title' ? (
|
||||
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-2 bg-white dark:bg-gray-800 rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Account Number</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white font-mono">{bankDetails.account_number}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(bankDetails.account_number, 'Account number')}
|
||||
className="p-1.5 text-gray-400 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
{copiedField === 'Account number' ? (
|
||||
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bankDetails.iban && (
|
||||
<div className="flex justify-between items-center p-2 bg-white dark:bg-gray-800 rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">IBAN</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white font-mono text-sm">{bankDetails.iban}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(bankDetails.iban!, 'IBAN')}
|
||||
className="p-1.5 text-gray-400 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
{copiedField === 'IBAN' ? (
|
||||
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reference to include */}
|
||||
<div className="mt-4 p-3 bg-brand-50 dark:bg-brand-900/20 rounded-lg border border-brand-200 dark:border-brand-800">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400 font-medium">Payment Reference (Include in transfer)</p>
|
||||
<p className="font-mono font-semibold text-brand-700 dark:text-brand-300">{invoice.invoice_number}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(invoice.invoice_number, 'Reference')}
|
||||
className="p-1.5 text-brand-400 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
{copiedField === 'Reference' ? (
|
||||
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="flex justify-between items-center pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="font-medium text-gray-900 dark:text-white">Amount to Transfer</span>
|
||||
<span className="text-xl font-bold text-brand-600 dark:text-brand-400">
|
||||
{invoice.currency === 'PKR' ? 'PKR ' : '$'}{invoice.total_amount || invoice.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label>
|
||||
Transaction Reference <span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Enter your bank transaction reference/ID"
|
||||
value={formData.reference}
|
||||
onChange={(e) => setFormData({ ...formData, reference: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter the transaction ID or reference from your bank receipt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Notes (Optional)</Label>
|
||||
<TextArea
|
||||
placeholder="Any additional information about your transfer"
|
||||
value={formData.notes}
|
||||
onChange={(value) => setFormData({ ...formData, notes: value })}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
disabled={loading || !formData.reference.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<Loader2Icon className="w-4 h-4 animate-spin mr-2" />
|
||||
Submitting...
|
||||
</span>
|
||||
) : (
|
||||
'Submit Payment Proof'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Info notice */}
|
||||
<div className="mt-4 flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<FileTextIcon className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
After submitting, our team will verify your payment within 24 hours.
|
||||
You'll receive an email confirmation once approved.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
339
frontend/src/components/billing/PendingPaymentView.tsx
Normal file
339
frontend/src/components/billing/PendingPaymentView.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* PendingPaymentView - Full-page view for new users with pending payment
|
||||
*
|
||||
* This is shown to users who:
|
||||
* - Just signed up with a paid plan
|
||||
* - Have account.status === 'pending_payment'
|
||||
* - Have NOT made any successful payments yet
|
||||
*
|
||||
* Payment methods are shown based on user's country:
|
||||
* - Global: Stripe (Credit/Debit Card) + PayPal
|
||||
* - Pakistan (PK): Stripe (Credit/Debit Card) + Bank Transfer
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
CreditCardIcon,
|
||||
Building2Icon,
|
||||
CheckCircleIcon,
|
||||
AlertCircleIcon,
|
||||
Loader2Icon,
|
||||
ArrowLeftIcon,
|
||||
LockIcon,
|
||||
} from '../../icons';
|
||||
import { Card } from '../ui/card';
|
||||
import Badge from '../ui/badge/Badge';
|
||||
import Button from '../ui/button/Button';
|
||||
import { useToast } from '../ui/toast/ToastContainer';
|
||||
import BankTransferForm from './BankTransferForm';
|
||||
import {
|
||||
Invoice,
|
||||
getAvailablePaymentGateways,
|
||||
subscribeToPlan,
|
||||
type PaymentGateway,
|
||||
} from '../../services/billing.api';
|
||||
|
||||
// PayPal icon component
|
||||
const PayPalIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.607-.541c-.013.076-.026.175-.041.254-.93 4.778-4.005 7.201-9.138 7.201h-2.19a.563.563 0 0 0-.556.479l-1.187 7.527h-.506l-.24 1.516a.56.56 0 0 0 .554.647h3.882c.46 0 .85-.334.922-.788.06-.26.76-4.852.816-5.09a.932.932 0 0 1 .923-.788h.58c3.76 0 6.705-1.528 7.565-5.946.36-1.847.174-3.388-.777-4.471z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface PaymentOption {
|
||||
id: string;
|
||||
type: PaymentGateway;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface PendingPaymentViewProps {
|
||||
invoice: Invoice | null;
|
||||
userCountry: string;
|
||||
planName: string;
|
||||
planPrice: string;
|
||||
onPaymentSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function PendingPaymentView({
|
||||
invoice,
|
||||
userCountry,
|
||||
planName,
|
||||
planPrice,
|
||||
onPaymentSuccess,
|
||||
}: PendingPaymentViewProps) {
|
||||
const toast = useToast();
|
||||
const [selectedGateway, setSelectedGateway] = useState<PaymentGateway>('stripe');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [gatewaysLoading, setGatewaysLoading] = useState(true);
|
||||
const [paymentOptions, setPaymentOptions] = useState<PaymentOption[]>([]);
|
||||
const [showBankTransfer, setShowBankTransfer] = useState(false);
|
||||
|
||||
const isPakistan = userCountry === 'PK';
|
||||
|
||||
// Load available payment gateways
|
||||
useEffect(() => {
|
||||
const loadGateways = async () => {
|
||||
setGatewaysLoading(true);
|
||||
try {
|
||||
const gateways = await getAvailablePaymentGateways();
|
||||
const options: PaymentOption[] = [];
|
||||
|
||||
// Always show Stripe (Credit Card) if available
|
||||
if (gateways.stripe) {
|
||||
options.push({
|
||||
id: 'stripe',
|
||||
type: 'stripe',
|
||||
name: 'Credit/Debit Card',
|
||||
description: 'Pay securely with Visa, Mastercard, or other cards',
|
||||
icon: <CreditCardIcon className="w-6 h-6" />,
|
||||
});
|
||||
}
|
||||
|
||||
// For Pakistan: show Bank Transfer
|
||||
// For Global: show PayPal
|
||||
if (isPakistan) {
|
||||
if (gateways.manual) {
|
||||
options.push({
|
||||
id: 'bank_transfer',
|
||||
type: 'manual',
|
||||
name: 'Bank Transfer',
|
||||
description: 'Pay via local bank transfer (PKR)',
|
||||
icon: <Building2Icon className="w-6 h-6" />,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (gateways.paypal) {
|
||||
options.push({
|
||||
id: 'paypal',
|
||||
type: 'paypal',
|
||||
name: 'PayPal',
|
||||
description: 'Pay with your PayPal account',
|
||||
icon: <PayPalIcon className="w-6 h-6" />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setPaymentOptions(options);
|
||||
if (options.length > 0) {
|
||||
setSelectedGateway(options[0].type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load payment gateways:', error);
|
||||
// Fallback to Stripe
|
||||
setPaymentOptions([{
|
||||
id: 'stripe',
|
||||
type: 'stripe',
|
||||
name: 'Credit/Debit Card',
|
||||
description: 'Pay securely with Visa, Mastercard, or other cards',
|
||||
icon: <CreditCardIcon className="w-6 h-6" />,
|
||||
}]);
|
||||
} finally {
|
||||
setGatewaysLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadGateways();
|
||||
}, [isPakistan]);
|
||||
|
||||
const handlePayNow = async () => {
|
||||
if (!invoice) {
|
||||
toast?.error?.('No invoice found');
|
||||
return;
|
||||
}
|
||||
|
||||
// For bank transfer, show the bank transfer form
|
||||
if (selectedGateway === 'manual') {
|
||||
setShowBankTransfer(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Get plan ID from invoice subscription
|
||||
const planId = invoice.subscription?.plan?.id;
|
||||
if (!planId) {
|
||||
throw new Error('Plan information not found');
|
||||
}
|
||||
|
||||
// Create checkout session
|
||||
const { redirect_url } = await subscribeToPlan(planId.toString(), selectedGateway);
|
||||
|
||||
// Redirect to payment gateway
|
||||
window.location.href = redirect_url;
|
||||
} catch (error: any) {
|
||||
console.error('Payment initiation failed:', error);
|
||||
toast?.error?.(error.message || 'Failed to start payment process');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If showing bank transfer form
|
||||
if (showBankTransfer && invoice) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12 px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={() => setShowBankTransfer(false)}
|
||||
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-6"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Back to payment options
|
||||
</button>
|
||||
|
||||
<BankTransferForm
|
||||
invoice={invoice}
|
||||
onSuccess={() => {
|
||||
setShowBankTransfer(false);
|
||||
onPaymentSuccess();
|
||||
}}
|
||||
onCancel={() => setShowBankTransfer(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-brand-950 py-12 px-4">
|
||||
<div className="max-w-xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-brand-100 dark:bg-brand-900/30 mb-4">
|
||||
<CheckCircleIcon className="w-8 h-8 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Account Created!
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Complete your payment to activate your <strong>{planName}</strong> plan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plan Summary Card */}
|
||||
<Card className="p-6 mb-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Order Summary</h2>
|
||||
<Badge variant="outline" tone="brand">{planName}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 py-4 border-t border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">{planName} Plan (Monthly)</span>
|
||||
<span className="text-gray-900 dark:text-white">${planPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">Total</span>
|
||||
<span className="text-2xl font-bold text-brand-600 dark:text-brand-400">${planPrice}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payment Method Selection */}
|
||||
<Card className="p-6 mb-6 border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Select Payment Method
|
||||
</h2>
|
||||
|
||||
{gatewaysLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2Icon className="w-6 h-6 animate-spin text-brand-500 mr-2" />
|
||||
<span className="text-gray-500">Loading payment options...</span>
|
||||
</div>
|
||||
) : paymentOptions.length === 0 ? (
|
||||
<div className="p-4 bg-warning-50 border border-warning-200 rounded-lg dark:bg-warning-900/20 dark:border-warning-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircleIcon className="w-5 h-5 text-warning-600" />
|
||||
<span className="text-warning-800 dark:text-warning-200">No payment methods available</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paymentOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedGateway(option.type)}
|
||||
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
selectedGateway === option.type
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{selectedGateway === option.type && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<div className="w-5 h-5 bg-brand-500 rounded-full flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center justify-center w-12 h-12 rounded-lg ${
|
||||
selectedGateway === option.type
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${
|
||||
selectedGateway === option.type
|
||||
? 'text-brand-700 dark:text-brand-400'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{option.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Badge */}
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<LockIcon className="w-4 h-4 text-green-600" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Your payment information is secure and encrypted
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pay Now Button */}
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
size="lg"
|
||||
onClick={handlePayNow}
|
||||
disabled={loading || gatewaysLoading || paymentOptions.length === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<Loader2Icon className="w-5 h-5 animate-spin mr-2" />
|
||||
Processing...
|
||||
</span>
|
||||
) : selectedGateway === 'manual' ? (
|
||||
'Continue to Bank Transfer'
|
||||
) : (
|
||||
`Pay $${planPrice} Now`
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Info text */}
|
||||
<p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedGateway === 'manual'
|
||||
? 'You will receive bank details to complete your transfer'
|
||||
: 'You will be redirected to complete payment securely'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import SignUpFormUnified from "../../components/auth/SignUpFormUnified";
|
||||
|
||||
@@ -19,7 +19,6 @@ interface Plan {
|
||||
}
|
||||
|
||||
export default function SignUp() {
|
||||
const navigate = useNavigate();
|
||||
const planSlug = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("plan") || "";
|
||||
@@ -28,38 +27,10 @@ export default function SignUp() {
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [plansLoading, setPlansLoading] = useState(true);
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||
const [geoChecked, setGeoChecked] = useState(false);
|
||||
|
||||
// Check geo location and redirect PK users to /signup/pk
|
||||
// Using free public API: https://api.country.is (no signup required, CORS enabled)
|
||||
useEffect(() => {
|
||||
const checkGeoAndRedirect = async () => {
|
||||
try {
|
||||
// Free public geo API - no signup required
|
||||
const response = await fetch('https://api.country.is/', {
|
||||
signal: AbortSignal.timeout(3000), // 3 second timeout
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const countryCode = data?.country;
|
||||
|
||||
if (countryCode === 'PK') {
|
||||
// Preserve query params when redirecting
|
||||
const queryString = window.location.search;
|
||||
navigate(`/signup/pk${queryString}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently fail - continue with global signup
|
||||
console.log('Geo detection failed, using global signup');
|
||||
}
|
||||
setGeoChecked(true);
|
||||
};
|
||||
|
||||
checkGeoAndRedirect();
|
||||
}, [navigate]);
|
||||
// NOTE: Geo detection removed per payment system refactor
|
||||
// Country is now selected via dropdown in the signup form
|
||||
// Payment method selection happens on /account/plans after registration
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
@@ -101,15 +72,6 @@ export default function SignUp() {
|
||||
fetchPlans();
|
||||
}, [planSlug]);
|
||||
|
||||
// Don't render until geo check is complete (prevents flash)
|
||||
if (!geoChecked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* SignUpPK - Pakistan-specific signup page
|
||||
* Shows Credit/Debit Card + Bank Transfer payment options
|
||||
*
|
||||
* Route: /signup/pk
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import SignUpFormUnified from "../../components/auth/SignUpFormUnified";
|
||||
|
||||
interface Plan {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
price: string | number;
|
||||
billing_cycle: string;
|
||||
is_active: boolean;
|
||||
max_users: number;
|
||||
max_sites: number;
|
||||
max_keywords: number;
|
||||
max_ahrefs_queries: number;
|
||||
included_credits: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export default function SignUpPK() {
|
||||
const planSlug = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("plan") || "";
|
||||
}, []);
|
||||
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [plansLoading, setPlansLoading] = useState(true);
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
setPlansLoading(true);
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api";
|
||||
const res = await fetch(`${API_BASE_URL}/v1/auth/plans/`);
|
||||
const data = await res.json();
|
||||
const allPlans = data?.results || [];
|
||||
|
||||
// Show all active plans (including free plan)
|
||||
const publicPlans = allPlans
|
||||
.filter((p: Plan) => p.is_active)
|
||||
.sort((a: Plan, b: Plan) => {
|
||||
const priceA = typeof a.price === 'number' ? a.price : parseFloat(String(a.price || 0));
|
||||
const priceB = typeof b.price === 'number' ? b.price : parseFloat(String(b.price || 0));
|
||||
return priceA - priceB;
|
||||
});
|
||||
|
||||
setPlans(publicPlans);
|
||||
|
||||
// Auto-select plan from URL or default to first plan
|
||||
if (planSlug) {
|
||||
const plan = publicPlans.find((p: Plan) => p.slug === planSlug);
|
||||
if (plan) {
|
||||
setSelectedPlan(plan);
|
||||
} else {
|
||||
setSelectedPlan(publicPlans[0] || null);
|
||||
}
|
||||
} else {
|
||||
setSelectedPlan(publicPlans[0] || null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load plans:', e);
|
||||
} finally {
|
||||
setPlansLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPlans();
|
||||
}, [planSlug]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="Sign Up (Pakistan) - IGNY8"
|
||||
description="Create your IGNY8 account and start building topical authority with AI-powered content"
|
||||
/>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Side - Signup Form with Pakistan-specific payment options */}
|
||||
<SignUpFormUnified
|
||||
plans={plans}
|
||||
selectedPlan={selectedPlan}
|
||||
onPlanSelect={setSelectedPlan}
|
||||
plansLoading={plansLoading}
|
||||
countryCode="PK"
|
||||
/>
|
||||
|
||||
{/* Right Side - Pricing Plans */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-brand-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-8 xl:p-12 items-start justify-center relative">
|
||||
{/* Logo - Top Right */}
|
||||
<Link to="/" className="absolute top-6 right-6">
|
||||
<img
|
||||
src="/images/logo/IGNY8_LIGHT_LOGO.png"
|
||||
alt="IGNY8"
|
||||
className="h-12 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="w-full max-w-2xl mt-20">
|
||||
{/* Pricing Plans Component Will Load Here */}
|
||||
<div id="signup-pricing-plans" className="w-full">
|
||||
{/* Plans will be rendered by SignUpFormUnified */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
} from '../../services/billing.api';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import PayInvoiceModal from '../../components/billing/PayInvoiceModal';
|
||||
import PendingPaymentView from '../../components/billing/PendingPaymentView';
|
||||
|
||||
export default function PlansAndBillingPage() {
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
@@ -385,6 +386,15 @@ export default function PlansAndBillingPage() {
|
||||
const hasPendingPayment = payments.some((p) => p.status === 'pending_approval');
|
||||
const hasPendingInvoice = invoices.some((inv) => inv.status === 'pending');
|
||||
|
||||
// Detect new user pending payment scenario:
|
||||
// - account status is 'pending_payment'
|
||||
// - user has never made a successful payment
|
||||
const accountStatus = user?.account?.status || '';
|
||||
const hasEverPaid = payments.some((p) => p.status === 'succeeded' || p.status === 'completed');
|
||||
const isNewUserPendingPayment = accountStatus === 'pending_payment' && !hasEverPaid;
|
||||
const pendingInvoice = invoices.find((inv) => inv.status === 'pending');
|
||||
const billingCountry = (user?.account as any)?.billing_country || 'US';
|
||||
|
||||
// Combined check: disable Buy Credits if no active plan OR has pending invoice
|
||||
const canBuyCredits = hasActivePlan && !hasPendingInvoice;
|
||||
|
||||
@@ -399,6 +409,29 @@ export default function PlansAndBillingPage() {
|
||||
return price > 0 && p.id !== effectivePlanId;
|
||||
}).sort((a, b) => (Number(a.price) || 0) - (Number(b.price) || 0));
|
||||
|
||||
// NEW USER PENDING PAYMENT - Show full-page payment view
|
||||
// This is the simplified flow for users who just signed up with a paid plan
|
||||
if (isNewUserPendingPayment && pendingInvoice) {
|
||||
const planName = currentPlan?.name || pendingInvoice.subscription?.plan?.name || 'Selected Plan';
|
||||
const planPrice = pendingInvoice.total_amount || pendingInvoice.total || '0';
|
||||
|
||||
return (
|
||||
<PendingPaymentView
|
||||
invoice={pendingInvoice}
|
||||
userCountry={billingCountry}
|
||||
planName={planName}
|
||||
planPrice={planPrice}
|
||||
onPaymentSuccess={() => {
|
||||
// Refresh user and billing data
|
||||
const { refreshUser } = useAuthStore.getState();
|
||||
refreshUser().catch(() => {});
|
||||
loadData();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// EXISTING USER - Show normal billing dashboard
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Plans & Billing" description="Manage your subscription and payments" />
|
||||
|
||||
@@ -357,19 +357,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
throw new Error('Failed to save login session. Please try again.');
|
||||
}
|
||||
|
||||
// Return full response data for success handling (includes checkout_url for payment redirects)
|
||||
console.log('Extracting checkout_url:', {
|
||||
'responseData.checkout_url': responseData.checkout_url,
|
||||
'data.checkout_url': data.checkout_url,
|
||||
'data.data?.checkout_url': data.data?.checkout_url,
|
||||
responseData: responseData,
|
||||
});
|
||||
|
||||
// Return full response data for success handling
|
||||
// Note: checkout_url is no longer returned by backend (payment flow moved to /account/plans)
|
||||
return {
|
||||
...userData,
|
||||
checkout_url: responseData.checkout_url || data.checkout_url || data.data?.checkout_url,
|
||||
checkout_session_id: responseData.checkout_session_id || data.checkout_session_id || data.data?.checkout_session_id,
|
||||
paypal_order_id: responseData.paypal_order_id || data.paypal_order_id || data.data?.paypal_order_id,
|
||||
};
|
||||
} catch (error: any) {
|
||||
// ALWAYS reset loading on error - critical to prevent stuck state
|
||||
|
||||
Reference in New Issue
Block a user