feat(billing): add missing payment methods and configurations
- Added migration to include global payment method configurations for Stripe and PayPal (both disabled). - Ensured existing payment methods like bank transfer and manual payment are correctly configured. - Added database constraints and indexes for improved data integrity in billing models. - Introduced foreign key relationship between CreditTransaction and Payment models. - Added webhook configuration fields to PaymentMethodConfig for future payment gateway integrations. - Updated SignUpFormUnified component to handle payment method selection based on user country and plan. - Implemented PaymentHistory component to display user's payment history with status indicators.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# Generated migration to fix subscription constraints
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0011_remove_subscription_payment_method'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add unique constraint on tenant_id at database level
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS igny8_subscriptions_tenant_id_unique
|
||||
ON igny8_subscriptions(tenant_id);
|
||||
""",
|
||||
reverse_sql="""
|
||||
DROP INDEX IF EXISTS igny8_subscriptions_tenant_id_unique;
|
||||
"""
|
||||
),
|
||||
|
||||
# Make plan field required (non-nullable)
|
||||
# First set default plan (ID 1 - Free Plan) for any null values
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
UPDATE igny8_subscriptions
|
||||
SET plan_id = 1
|
||||
WHERE plan_id IS NULL;
|
||||
""",
|
||||
reverse_sql=migrations.RunSQL.noop
|
||||
),
|
||||
|
||||
# Now alter the field to be non-nullable
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='plan',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='subscriptions',
|
||||
to='igny8_core_auth.plan',
|
||||
help_text='Subscription plan (tracks historical plan even if account changes plan)'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -249,8 +249,6 @@ class Subscription(models.Model):
|
||||
'igny8_core_auth.Plan',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='subscriptions',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Subscription plan (tracks historical plan even if account changes plan)'
|
||||
)
|
||||
stripe_subscription_id = models.CharField(
|
||||
|
||||
@@ -235,6 +235,9 @@ class SiteUserAccessSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ['granted_at']
|
||||
|
||||
|
||||
from igny8_core.business.billing.models import PAYMENT_METHOD_CHOICES
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
account = AccountSerializer(read_only=True)
|
||||
accessible_sites = serializers.SerializerMethodField()
|
||||
@@ -267,7 +270,7 @@ class RegisterSerializer(serializers.Serializer):
|
||||
)
|
||||
plan_slug = serializers.CharField(max_length=50, required=False)
|
||||
payment_method = serializers.ChoiceField(
|
||||
choices=['stripe', 'paypal', 'bank_transfer', 'local_wallet'],
|
||||
choices=[choice[0] for choice in PAYMENT_METHOD_CHOICES],
|
||||
default='bank_transfer',
|
||||
required=False
|
||||
)
|
||||
@@ -291,6 +294,21 @@ class RegisterSerializer(serializers.Serializer):
|
||||
if 'plan_id' in attrs and attrs.get('plan_id') == '':
|
||||
attrs['plan_id'] = None
|
||||
|
||||
# Validate billing fields for paid plans
|
||||
plan_slug = attrs.get('plan_slug')
|
||||
paid_plans = ['starter', 'growth', 'scale']
|
||||
if plan_slug and plan_slug in paid_plans:
|
||||
# Require billing_country for paid plans
|
||||
if not attrs.get('billing_country'):
|
||||
raise serializers.ValidationError({
|
||||
"billing_country": "Billing country is required for paid plans."
|
||||
})
|
||||
# Require payment_method for paid plans
|
||||
if not attrs.get('payment_method'):
|
||||
raise serializers.ValidationError({
|
||||
"payment_method": "Payment method is required for paid plans."
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
|
||||
@@ -46,12 +46,36 @@ class RegisterView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
from .utils import generate_access_token, generate_refresh_token, get_token_expiry
|
||||
from django.contrib.auth import login
|
||||
|
||||
serializer = RegisterSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
user = serializer.save()
|
||||
|
||||
# Log the user in (create session for session authentication)
|
||||
login(request, user)
|
||||
|
||||
# Get account from user
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Generate JWT tokens
|
||||
access_token = generate_access_token(user, account)
|
||||
refresh_token = generate_refresh_token(user, account)
|
||||
access_expires_at = get_token_expiry('access')
|
||||
refresh_expires_at = get_token_expiry('refresh')
|
||||
|
||||
user_serializer = UserSerializer(user)
|
||||
return success_response(
|
||||
data={'user': user_serializer.data},
|
||||
data={
|
||||
'user': user_serializer.data,
|
||||
'tokens': {
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
}
|
||||
},
|
||||
message='Registration successful',
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
request=request
|
||||
|
||||
@@ -139,13 +139,8 @@ class CreditPackageAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
@admin.register(PaymentMethodConfig)
|
||||
class PaymentMethodConfigAdmin(admin.ModelAdmin):
|
||||
list_display = ['country_code', 'payment_method', 'is_enabled', 'display_name', 'sort_order']
|
||||
list_filter = ['payment_method', 'is_enabled', 'country_code']
|
||||
search_fields = ['country_code', 'display_name', 'payment_method']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
# PaymentMethodConfig admin is in modules/billing/admin.py - do not duplicate
|
||||
# @admin.register(PaymentMethodConfig)
|
||||
|
||||
@admin.register(AccountPaymentMethod)
|
||||
class AccountPaymentMethodAdmin(admin.ModelAdmin):
|
||||
|
||||
37
backend/igny8_core/business/billing/config.py
Normal file
37
backend/igny8_core/business/billing/config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Billing configuration settings
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
# Payment Gateway Mode
|
||||
PAYMENT_GATEWAY_MODE = getattr(settings, 'PAYMENT_GATEWAY_MODE', 'sandbox') # 'sandbox' or 'production'
|
||||
|
||||
# Auto-approve payments (development only)
|
||||
AUTO_APPROVE_PAYMENTS = getattr(settings, 'AUTO_APPROVE_PAYMENTS', False)
|
||||
|
||||
# Invoice due date offset (days)
|
||||
INVOICE_DUE_DATE_OFFSET = getattr(settings, 'INVOICE_DUE_DATE_OFFSET', 7)
|
||||
|
||||
# Grace period for payment (days)
|
||||
PAYMENT_GRACE_PERIOD = getattr(settings, 'PAYMENT_GRACE_PERIOD', 7)
|
||||
|
||||
# Maximum payment retry attempts
|
||||
MAX_PAYMENT_RETRIES = getattr(settings, 'MAX_PAYMENT_RETRIES', 3)
|
||||
|
||||
# Subscription renewal advance notice (days)
|
||||
SUBSCRIPTION_RENEWAL_NOTICE_DAYS = getattr(settings, 'SUBSCRIPTION_RENEWAL_NOTICE_DAYS', 7)
|
||||
|
||||
# Default subscription plan slugs
|
||||
DEFAULT_PLAN_SLUGS = {
|
||||
'free': getattr(settings, 'FREE_PLAN_SLUG', 'basic-free'),
|
||||
'starter': getattr(settings, 'STARTER_PLAN_SLUG', 'starter-10'),
|
||||
'professional': getattr(settings, 'PROFESSIONAL_PLAN_SLUG', 'professional-100'),
|
||||
'enterprise': getattr(settings, 'ENTERPRISE_PLAN_SLUG', 'enterprise-unlimited'),
|
||||
}
|
||||
|
||||
# Credit package slugs
|
||||
DEFAULT_CREDIT_PACKAGES = {
|
||||
'small': getattr(settings, 'SMALL_CREDIT_PACKAGE_SLUG', 'credits-100'),
|
||||
'medium': getattr(settings, 'MEDIUM_CREDIT_PACKAGE_SLUG', 'credits-500'),
|
||||
'large': getattr(settings, 'LARGE_CREDIT_PACKAGE_SLUG', 'credits-1000'),
|
||||
}
|
||||
@@ -8,6 +8,16 @@ from django.conf import settings
|
||||
from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
|
||||
# Centralized payment method choices - single source of truth
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe (Credit/Debit Card)'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer (Manual)'),
|
||||
('local_wallet', 'Local Wallet (Manual)'),
|
||||
('manual', 'Manual Payment'),
|
||||
]
|
||||
|
||||
|
||||
class CreditTransaction(AccountBaseModel):
|
||||
"""Track all credit transactions (additions, deductions)"""
|
||||
TRANSACTION_TYPE_CHOICES = [
|
||||
@@ -23,11 +33,24 @@ class CreditTransaction(AccountBaseModel):
|
||||
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
|
||||
description = models.CharField(max_length=255)
|
||||
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
|
||||
|
||||
# Payment FK - preferred over reference_id string
|
||||
payment = models.ForeignKey(
|
||||
'billing.Payment',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='credit_transactions',
|
||||
help_text='Payment that triggered this credit transaction'
|
||||
)
|
||||
|
||||
# Deprecated: Use payment FK instead
|
||||
reference_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Optional reference (e.g., payment id, invoice id)"
|
||||
help_text="DEPRECATED: Use payment FK. Legacy reference (e.g., payment id, invoice id)"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
@@ -181,6 +204,16 @@ class Invoice(AccountBaseModel):
|
||||
|
||||
invoice_number = models.CharField(max_length=50, unique=True, db_index=True)
|
||||
|
||||
# Subscription relationship
|
||||
subscription = models.ForeignKey(
|
||||
'igny8_core_auth.Subscription',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='invoices',
|
||||
help_text='Subscription this invoice is for (if subscription-based)'
|
||||
)
|
||||
|
||||
# Amounts
|
||||
subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||
tax = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||
@@ -295,13 +328,8 @@ class Payment(AccountBaseModel):
|
||||
('refunded', 'Refunded'), # Payment refunded (rare)
|
||||
]
|
||||
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe (Credit/Debit Card)'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer (Manual)'),
|
||||
('local_wallet', 'Local Wallet (Manual)'),
|
||||
('manual', 'Manual Payment'),
|
||||
]
|
||||
# Use centralized payment method choices
|
||||
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
||||
|
||||
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments')
|
||||
|
||||
@@ -310,7 +338,7 @@ class Payment(AccountBaseModel):
|
||||
currency = models.CharField(max_length=3, default='USD')
|
||||
|
||||
# Status
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending_approval', db_index=True)
|
||||
|
||||
# Payment method
|
||||
payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True)
|
||||
@@ -367,85 +395,6 @@ class Payment(AccountBaseModel):
|
||||
def __str__(self):
|
||||
return f"Payment {self.id} - {self.get_payment_method_display()} - {self.amount} {self.currency}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save to automatically update related objects when payment is approved.
|
||||
When status changes to 'succeeded', automatically:
|
||||
1. Mark invoice as paid
|
||||
2. Activate subscription
|
||||
3. Activate account
|
||||
4. Add credits
|
||||
"""
|
||||
# Check if status is changing to succeeded
|
||||
is_new = self.pk is None
|
||||
old_status = None
|
||||
|
||||
if not is_new:
|
||||
try:
|
||||
old_payment = Payment.objects.get(pk=self.pk)
|
||||
old_status = old_payment.status
|
||||
except Payment.DoesNotExist:
|
||||
pass
|
||||
|
||||
# If status is changing to succeeded, trigger approval workflow
|
||||
if self.status == 'succeeded' and old_status != 'succeeded':
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
|
||||
# Set approval timestamp if not set
|
||||
if not self.processed_at:
|
||||
self.processed_at = timezone.now()
|
||||
if not self.approved_at:
|
||||
self.approved_at = timezone.now()
|
||||
|
||||
# Save payment first
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Then update related objects in transaction
|
||||
with transaction.atomic():
|
||||
# 1. Update Invoice
|
||||
if self.invoice:
|
||||
self.invoice.status = 'paid'
|
||||
self.invoice.paid_at = timezone.now()
|
||||
self.invoice.save(update_fields=['status', 'paid_at'])
|
||||
|
||||
# 2. Update Account (MUST be before subscription check)
|
||||
if self.account:
|
||||
self.account.status = 'active'
|
||||
self.account.save(update_fields=['status'])
|
||||
|
||||
# 3. Update Subscription via account.subscription (one-to-one relationship)
|
||||
try:
|
||||
if hasattr(self.account, 'subscription'):
|
||||
subscription = self.account.subscription
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = self.manual_reference or f'payment-{self.id}'
|
||||
subscription.save(update_fields=['status', 'external_payment_id'])
|
||||
|
||||
# 4. Add Credits from subscription plan
|
||||
if subscription.plan and subscription.plan.included_credits > 0:
|
||||
CreditService.add_credits(
|
||||
account=self.account,
|
||||
amount=subscription.plan.included_credits,
|
||||
transaction_type='subscription',
|
||||
description=f'{subscription.plan.name} - Invoice {self.invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id,
|
||||
'invoice_id': self.invoice.id,
|
||||
'payment_id': self.id,
|
||||
'auto_approved': True
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't fail payment save
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error updating subscription/credits for payment {self.id}: {e}', exc_info=True)
|
||||
else:
|
||||
# Normal save
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CreditPackage(models.Model):
|
||||
"""
|
||||
@@ -497,12 +446,8 @@ class PaymentMethodConfig(models.Model):
|
||||
Configure payment methods availability per country
|
||||
Allows enabling/disabling manual payments by region
|
||||
"""
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer'),
|
||||
('local_wallet', 'Local Wallet'),
|
||||
]
|
||||
# Use centralized choices
|
||||
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
||||
|
||||
country_code = models.CharField(
|
||||
max_length=2,
|
||||
@@ -526,6 +471,12 @@ class PaymentMethodConfig(models.Model):
|
||||
wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., PayTM, PhonePe, etc.")
|
||||
wallet_id = models.CharField(max_length=255, blank=True)
|
||||
|
||||
# Webhook configuration (Stripe/PayPal)
|
||||
webhook_url = models.URLField(blank=True, help_text="Webhook URL for payment gateway callbacks")
|
||||
webhook_secret = models.CharField(max_length=255, blank=True, help_text="Webhook secret for signature verification")
|
||||
api_key = models.CharField(max_length=255, blank=True, help_text="API key for payment gateway integration")
|
||||
api_secret = models.CharField(max_length=255, blank=True, help_text="API secret for payment gateway integration")
|
||||
|
||||
# Order/priority
|
||||
sort_order = models.IntegerField(default=0)
|
||||
|
||||
@@ -549,12 +500,8 @@ class AccountPaymentMethod(AccountBaseModel):
|
||||
Account-scoped payment methods (Stripe/PayPal/manual bank/wallet).
|
||||
Only metadata/refs are stored here; no secrets.
|
||||
"""
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer'),
|
||||
('local_wallet', 'Local Wallet'),
|
||||
]
|
||||
# Use centralized choices
|
||||
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
||||
|
||||
type = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True)
|
||||
display_name = models.CharField(max_length=100, help_text="User-visible label", default='')
|
||||
|
||||
228
backend/igny8_core/business/billing/services/email_service.py
Normal file
228
backend/igny8_core/business/billing/services/email_service.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Email service for billing notifications
|
||||
"""
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BillingEmailService:
|
||||
"""Service for sending billing-related emails"""
|
||||
|
||||
@staticmethod
|
||||
def send_payment_confirmation_email(payment, account):
|
||||
"""
|
||||
Send email when user submits manual payment for approval
|
||||
"""
|
||||
subject = f'Payment Confirmation Received - Invoice #{payment.invoice.invoice_number}'
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'invoice_number': payment.invoice.invoice_number,
|
||||
'amount': payment.amount,
|
||||
'currency': payment.currency,
|
||||
'payment_method': payment.get_payment_method_display(),
|
||||
'manual_reference': payment.manual_reference,
|
||||
'created_at': payment.created_at,
|
||||
}
|
||||
|
||||
# Plain text message
|
||||
message = f"""
|
||||
Hi {account.name},
|
||||
|
||||
We have received your payment confirmation for Invoice #{payment.invoice.invoice_number}.
|
||||
|
||||
Payment Details:
|
||||
- Amount: {payment.currency} {payment.amount}
|
||||
- Payment Method: {payment.get_payment_method_display()}
|
||||
- Reference: {payment.manual_reference}
|
||||
- Submitted: {payment.created_at.strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
Your payment is currently under review. You will receive another email once it has been approved.
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[account.billing_email or account.owner.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info(f'Payment confirmation email sent for Payment {payment.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment confirmation email: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
def send_payment_approved_email(payment, account, subscription):
|
||||
"""
|
||||
Send email when payment is approved and account activated
|
||||
"""
|
||||
subject = f'Payment Approved - Account Activated'
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'invoice_number': payment.invoice.invoice_number,
|
||||
'amount': payment.amount,
|
||||
'currency': payment.currency,
|
||||
'plan_name': subscription.plan.name if subscription else 'N/A',
|
||||
'approved_at': payment.approved_at,
|
||||
}
|
||||
|
||||
message = f"""
|
||||
Hi {account.name},
|
||||
|
||||
Great news! Your payment has been approved and your account is now active.
|
||||
|
||||
Payment Details:
|
||||
- Invoice: #{payment.invoice.invoice_number}
|
||||
- Amount: {payment.currency} {payment.amount}
|
||||
- Plan: {subscription.plan.name if subscription else 'N/A'}
|
||||
- Approved: {payment.approved_at.strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
You can now access all features of your plan. Log in to get started!
|
||||
|
||||
Dashboard: {settings.FRONTEND_URL}/dashboard
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[account.billing_email or account.owner.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info(f'Payment approved email sent for Payment {payment.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment approved email: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
def send_payment_rejected_email(payment, account, reason):
|
||||
"""
|
||||
Send email when payment is rejected
|
||||
"""
|
||||
subject = f'Payment Declined - Action Required'
|
||||
|
||||
message = f"""
|
||||
Hi {account.name},
|
||||
|
||||
Unfortunately, we were unable to approve your payment for Invoice #{payment.invoice.invoice_number}.
|
||||
|
||||
Reason: {reason}
|
||||
|
||||
Payment Details:
|
||||
- Invoice: #{payment.invoice.invoice_number}
|
||||
- Amount: {payment.currency} {payment.amount}
|
||||
- Reference: {payment.manual_reference}
|
||||
|
||||
You can retry your payment by logging into your account:
|
||||
{settings.FRONTEND_URL}/billing
|
||||
|
||||
If you have questions, please contact our support team.
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[account.billing_email or account.owner.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info(f'Payment rejected email sent for Payment {payment.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment rejected email: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
def send_refund_notification(user, payment, refund_amount, reason):
|
||||
"""
|
||||
Send email when refund is processed
|
||||
"""
|
||||
subject = f'Refund Processed - Invoice #{payment.invoice.invoice_number}'
|
||||
|
||||
message = f"""
|
||||
Hi {user.first_name or user.email},
|
||||
|
||||
Your refund has been processed successfully.
|
||||
|
||||
Refund Details:
|
||||
- Invoice: #{payment.invoice.invoice_number}
|
||||
- Original Amount: {payment.currency} {payment.amount}
|
||||
- Refund Amount: {payment.currency} {refund_amount}
|
||||
- Reason: {reason}
|
||||
- Processed: {payment.refunded_at.strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
The refund will appear in your original payment method within 5-10 business days.
|
||||
|
||||
If you have any questions, please contact our support team.
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info(f'Refund notification email sent for Payment {payment.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send refund notification email: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
def send_subscription_renewal_notice(subscription, days_until_renewal):
|
||||
"""
|
||||
Send email reminder before subscription renewal
|
||||
"""
|
||||
subject = f'Subscription Renewal Reminder - {days_until_renewal} Days'
|
||||
|
||||
account = subscription.account
|
||||
user = account.owner
|
||||
|
||||
message = f"""
|
||||
Hi {account.name},
|
||||
|
||||
Your subscription will be renewed in {days_until_renewal} days.
|
||||
|
||||
Subscription Details:
|
||||
- Plan: {subscription.plan.name}
|
||||
- Renewal Date: {subscription.current_period_end.strftime('%Y-%m-%d')}
|
||||
- Amount: {subscription.plan.currency} {subscription.plan.price}
|
||||
|
||||
Your payment method will be charged automatically on the renewal date.
|
||||
|
||||
To manage your subscription or update payment details:
|
||||
{settings.FRONTEND_URL}/billing/subscription
|
||||
|
||||
Thank you,
|
||||
The Igny8 Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message.strip(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[account.billing_email or user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info(f'Renewal notice sent for Subscription {subscription.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send renewal notice: {str(e)}')
|
||||
|
||||
@@ -17,20 +17,31 @@ class InvoiceService:
|
||||
@staticmethod
|
||||
def generate_invoice_number(account: Account) -> str:
|
||||
"""
|
||||
Generate unique invoice number
|
||||
Generate unique invoice number with atomic locking to prevent duplicates
|
||||
Format: INV-{ACCOUNT_ID}-{YEAR}{MONTH}-{COUNTER}
|
||||
"""
|
||||
from django.db import transaction
|
||||
|
||||
now = timezone.now()
|
||||
prefix = f"INV-{account.id}-{now.year}{now.month:02d}"
|
||||
|
||||
# Get count of invoices for this account this month
|
||||
count = Invoice.objects.filter(
|
||||
# Use atomic transaction with SELECT FOR UPDATE to prevent race conditions
|
||||
with transaction.atomic():
|
||||
# Lock the invoice table for this account/month to get accurate count
|
||||
count = Invoice.objects.select_for_update().filter(
|
||||
account=account,
|
||||
created_at__year=now.year,
|
||||
created_at__month=now.month
|
||||
).count()
|
||||
|
||||
return f"{prefix}-{count + 1:04d}"
|
||||
invoice_number = f"{prefix}-{count + 1:04d}"
|
||||
|
||||
# Double-check uniqueness (should not happen with lock, but safety check)
|
||||
while Invoice.objects.filter(invoice_number=invoice_number).exists():
|
||||
count += 1
|
||||
invoice_number = f"{prefix}-{count + 1:04d}"
|
||||
|
||||
return invoice_number
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
@@ -58,27 +69,42 @@ class InvoiceService:
|
||||
'snapshot_date': timezone.now().isoformat()
|
||||
}
|
||||
|
||||
# For manual payments, use configurable grace period instead of billing_period_end
|
||||
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
|
||||
invoice_date = timezone.now().date()
|
||||
due_date = invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET)
|
||||
|
||||
# Get currency based on billing country
|
||||
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
||||
currency = get_currency_for_country(account.billing_country)
|
||||
|
||||
# Convert plan price to local currency
|
||||
local_price = convert_usd_to_local(float(plan.price), account.billing_country)
|
||||
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
subscription=subscription, # Set FK directly
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
status='pending',
|
||||
currency='USD',
|
||||
invoice_date=timezone.now().date(),
|
||||
due_date=billing_period_end.date(),
|
||||
currency=currency,
|
||||
invoice_date=invoice_date,
|
||||
due_date=due_date,
|
||||
metadata={
|
||||
'billing_snapshot': billing_snapshot,
|
||||
'billing_period_start': billing_period_start.isoformat(),
|
||||
'billing_period_end': billing_period_end.isoformat(),
|
||||
'subscription_id': subscription.id
|
||||
'subscription_id': subscription.id, # Keep in metadata for backward compatibility
|
||||
'usd_price': str(plan.price), # Store original USD price
|
||||
'exchange_rate': str(local_price / float(plan.price) if plan.price > 0 else 1.0)
|
||||
}
|
||||
)
|
||||
|
||||
# Add line item for subscription
|
||||
# Add line item for subscription with converted price
|
||||
invoice.add_line_item(
|
||||
description=f"{plan.name} Plan - {billing_period_start.strftime('%b %Y')}",
|
||||
quantity=1,
|
||||
unit_price=plan.price,
|
||||
amount=plan.price
|
||||
unit_price=Decimal(str(local_price)),
|
||||
amount=Decimal(str(local_price))
|
||||
)
|
||||
|
||||
invoice.calculate_totals()
|
||||
@@ -95,26 +121,38 @@ class InvoiceService:
|
||||
"""
|
||||
Create invoice for credit package purchase
|
||||
"""
|
||||
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
|
||||
invoice_date = timezone.now().date()
|
||||
|
||||
# Get currency based on billing country
|
||||
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
||||
currency = get_currency_for_country(account.billing_country)
|
||||
|
||||
# Convert credit package price to local currency
|
||||
local_price = convert_usd_to_local(float(credit_package.price), account.billing_country)
|
||||
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
billing_email=account.billing_email or account.users.filter(role='owner').first().email,
|
||||
status='pending',
|
||||
currency='USD',
|
||||
invoice_date=timezone.now().date(),
|
||||
due_date=timezone.now().date(),
|
||||
currency=currency,
|
||||
invoice_date=invoice_date,
|
||||
due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET),
|
||||
metadata={
|
||||
'credit_package_id': credit_package.id,
|
||||
'credit_amount': credit_package.credits,
|
||||
'usd_price': str(credit_package.price), # Store original USD price
|
||||
'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0)
|
||||
},
|
||||
)
|
||||
|
||||
# Add line item for credit package
|
||||
# Add line item for credit package with converted price
|
||||
invoice.add_line_item(
|
||||
description=f"{credit_package.name} - {credit_package.credits:,} Credits",
|
||||
quantity=1,
|
||||
unit_price=credit_package.price,
|
||||
amount=credit_package.price
|
||||
unit_price=Decimal(str(local_price)),
|
||||
amount=Decimal(str(local_price))
|
||||
)
|
||||
|
||||
invoice.calculate_totals()
|
||||
|
||||
246
backend/igny8_core/business/billing/services/pdf_service.py
Normal file
246
backend/igny8_core/business/billing/services/pdf_service.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Invoice PDF generation service
|
||||
Generates PDF invoices for billing records
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
|
||||
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvoicePDFGenerator:
|
||||
"""Generate PDF invoices"""
|
||||
|
||||
@staticmethod
|
||||
def generate_invoice_pdf(invoice):
|
||||
"""
|
||||
Generate PDF for an invoice
|
||||
|
||||
Args:
|
||||
invoice: Invoice model instance
|
||||
|
||||
Returns:
|
||||
BytesIO: PDF file buffer
|
||||
"""
|
||||
buffer = BytesIO()
|
||||
|
||||
# Create PDF document
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=letter,
|
||||
rightMargin=0.75*inch,
|
||||
leftMargin=0.75*inch,
|
||||
topMargin=0.75*inch,
|
||||
bottomMargin=0.75*inch
|
||||
)
|
||||
|
||||
# Container for PDF elements
|
||||
elements = []
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# Custom styles
|
||||
title_style = ParagraphStyle(
|
||||
'CustomTitle',
|
||||
parent=styles['Heading1'],
|
||||
fontSize=24,
|
||||
textColor=colors.HexColor('#1f2937'),
|
||||
spaceAfter=30,
|
||||
)
|
||||
|
||||
heading_style = ParagraphStyle(
|
||||
'CustomHeading',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=14,
|
||||
textColor=colors.HexColor('#374151'),
|
||||
spaceAfter=12,
|
||||
)
|
||||
|
||||
normal_style = ParagraphStyle(
|
||||
'CustomNormal',
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
textColor=colors.HexColor('#4b5563'),
|
||||
)
|
||||
|
||||
# Header
|
||||
elements.append(Paragraph('INVOICE', title_style))
|
||||
elements.append(Spacer(1, 0.2*inch))
|
||||
|
||||
# Company info and invoice details side by side
|
||||
company_data = [
|
||||
['<b>From:</b>', f'<b>Invoice #:</b> {invoice.invoice_number}'],
|
||||
[getattr(settings, 'COMPANY_NAME', 'Igny8'), f'<b>Date:</b> {invoice.created_at.strftime("%B %d, %Y")}'],
|
||||
[getattr(settings, 'COMPANY_ADDRESS', ''), f'<b>Due Date:</b> {invoice.due_date.strftime("%B %d, %Y")}'],
|
||||
[getattr(settings, 'COMPANY_EMAIL', settings.DEFAULT_FROM_EMAIL), f'<b>Status:</b> {invoice.status.upper()}'],
|
||||
]
|
||||
|
||||
company_table = Table(company_data, colWidths=[3.5*inch, 3*inch])
|
||||
company_table.setStyle(TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 10),
|
||||
('TEXTCOLOR', (0, 0), (-1, -1), colors.HexColor('#4b5563')),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
|
||||
]))
|
||||
elements.append(company_table)
|
||||
elements.append(Spacer(1, 0.3*inch))
|
||||
|
||||
# Bill to section
|
||||
elements.append(Paragraph('<b>Bill To:</b>', heading_style))
|
||||
bill_to_data = [
|
||||
[invoice.account.name],
|
||||
[invoice.account.owner.email],
|
||||
]
|
||||
|
||||
if hasattr(invoice.account, 'billing_email') and invoice.account.billing_email:
|
||||
bill_to_data.append([f'Billing: {invoice.account.billing_email}'])
|
||||
|
||||
for line in bill_to_data:
|
||||
elements.append(Paragraph(line[0], normal_style))
|
||||
|
||||
elements.append(Spacer(1, 0.3*inch))
|
||||
|
||||
# Line items table
|
||||
elements.append(Paragraph('<b>Items:</b>', heading_style))
|
||||
|
||||
# Table header
|
||||
line_items_data = [
|
||||
['Description', 'Quantity', 'Unit Price', 'Amount']
|
||||
]
|
||||
|
||||
# Get line items
|
||||
for item in invoice.line_items.all():
|
||||
line_items_data.append([
|
||||
item.description,
|
||||
str(item.quantity),
|
||||
f'{invoice.currency} {item.unit_price:.2f}',
|
||||
f'{invoice.currency} {item.total_price:.2f}'
|
||||
])
|
||||
|
||||
# Add subtotal, tax, total rows
|
||||
line_items_data.append(['', '', '<b>Subtotal:</b>', f'<b>{invoice.currency} {invoice.subtotal:.2f}</b>'])
|
||||
|
||||
if invoice.tax_amount and invoice.tax_amount > 0:
|
||||
line_items_data.append(['', '', f'Tax ({invoice.tax_rate}%):', f'{invoice.currency} {invoice.tax_amount:.2f}'])
|
||||
|
||||
if invoice.discount_amount and invoice.discount_amount > 0:
|
||||
line_items_data.append(['', '', 'Discount:', f'-{invoice.currency} {invoice.discount_amount:.2f}'])
|
||||
|
||||
line_items_data.append(['', '', '<b>Total:</b>', f'<b>{invoice.currency} {invoice.total_amount:.2f}</b>'])
|
||||
|
||||
# Create table
|
||||
line_items_table = Table(
|
||||
line_items_data,
|
||||
colWidths=[3*inch, 1*inch, 1.25*inch, 1.25*inch]
|
||||
)
|
||||
|
||||
line_items_table.setStyle(TableStyle([
|
||||
# Header row
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f3f4f6')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#1f2937')),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
|
||||
# Body rows
|
||||
('FONTNAME', (0, 1), (-1, -4), 'Helvetica'),
|
||||
('FONTSIZE', (0, 1), (-1, -4), 9),
|
||||
('TEXTCOLOR', (0, 1), (-1, -4), colors.HexColor('#4b5563')),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -4), [colors.white, colors.HexColor('#f9fafb')]),
|
||||
|
||||
# Summary rows (last 3-4 rows)
|
||||
('FONTNAME', (0, -4), (-1, -1), 'Helvetica'),
|
||||
('FONTSIZE', (0, -4), (-1, -1), 9),
|
||||
('ALIGN', (2, 0), (2, -1), 'RIGHT'),
|
||||
('ALIGN', (3, 0), (3, -1), 'RIGHT'),
|
||||
|
||||
# Grid
|
||||
('GRID', (0, 0), (-1, -4), 0.5, colors.HexColor('#e5e7eb')),
|
||||
('LINEABOVE', (2, -4), (-1, -4), 1, colors.HexColor('#d1d5db')),
|
||||
('LINEABOVE', (2, -1), (-1, -1), 2, colors.HexColor('#1f2937')),
|
||||
|
||||
# Padding
|
||||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
||||
]))
|
||||
|
||||
elements.append(line_items_table)
|
||||
elements.append(Spacer(1, 0.4*inch))
|
||||
|
||||
# Payment information
|
||||
if invoice.status == 'paid':
|
||||
elements.append(Paragraph('<b>Payment Information:</b>', heading_style))
|
||||
|
||||
payment = invoice.payments.filter(status='succeeded').first()
|
||||
if payment:
|
||||
payment_info = [
|
||||
f'Payment Method: {payment.get_payment_method_display()}',
|
||||
f'Paid On: {payment.processed_at.strftime("%B %d, %Y")}',
|
||||
]
|
||||
|
||||
if payment.manual_reference:
|
||||
payment_info.append(f'Reference: {payment.manual_reference}')
|
||||
|
||||
for line in payment_info:
|
||||
elements.append(Paragraph(line, normal_style))
|
||||
|
||||
elements.append(Spacer(1, 0.2*inch))
|
||||
|
||||
# Footer / Notes
|
||||
if invoice.notes:
|
||||
elements.append(Spacer(1, 0.2*inch))
|
||||
elements.append(Paragraph('<b>Notes:</b>', heading_style))
|
||||
elements.append(Paragraph(invoice.notes, normal_style))
|
||||
|
||||
# Terms
|
||||
elements.append(Spacer(1, 0.3*inch))
|
||||
elements.append(Paragraph('<b>Terms & Conditions:</b>', heading_style))
|
||||
terms = getattr(settings, 'INVOICE_TERMS', 'Payment is due within 7 days of invoice date.')
|
||||
elements.append(Paragraph(terms, normal_style))
|
||||
|
||||
# Build PDF
|
||||
doc.build(elements)
|
||||
|
||||
# Get PDF content
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
@staticmethod
|
||||
def save_invoice_pdf(invoice, file_path=None):
|
||||
"""
|
||||
Generate and save invoice PDF to file
|
||||
|
||||
Args:
|
||||
invoice: Invoice model instance
|
||||
file_path: Optional file path, defaults to media/invoices/
|
||||
|
||||
Returns:
|
||||
str: File path where PDF was saved
|
||||
"""
|
||||
import os
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
# Generate PDF
|
||||
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
|
||||
|
||||
# Determine file path
|
||||
if not file_path:
|
||||
file_path = f'invoices/{invoice.invoice_number}.pdf'
|
||||
|
||||
# Save to storage
|
||||
saved_path = default_storage.save(file_path, ContentFile(pdf_buffer.read()))
|
||||
|
||||
logger.info(f'Invoice PDF saved: {saved_path}')
|
||||
return saved_path
|
||||
178
backend/igny8_core/business/billing/tasks/payment_retry.py
Normal file
178
backend/igny8_core/business/billing/tasks/payment_retry.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Payment retry mechanism for failed payments
|
||||
Implements automatic retry logic with exponential backoff
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from celery import shared_task
|
||||
from igny8_core.business.billing.models import Payment
|
||||
from igny8_core.business.billing.config import MAX_PAYMENT_RETRIES
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name='billing.retry_failed_payment')
|
||||
def retry_failed_payment(payment_id: int):
|
||||
"""
|
||||
Retry a failed payment with exponential backoff
|
||||
|
||||
Args:
|
||||
payment_id: Payment ID to retry
|
||||
"""
|
||||
try:
|
||||
payment = Payment.objects.get(id=payment_id)
|
||||
|
||||
# Only retry failed payments
|
||||
if payment.status != 'failed':
|
||||
logger.info(f"Payment {payment_id} status is {payment.status}, skipping retry")
|
||||
return
|
||||
|
||||
# Check retry count
|
||||
retry_count = payment.metadata.get('retry_count', 0)
|
||||
if retry_count >= MAX_PAYMENT_RETRIES:
|
||||
logger.warning(f"Payment {payment_id} exceeded max retries ({MAX_PAYMENT_RETRIES})")
|
||||
payment.metadata['retry_exhausted'] = True
|
||||
payment.save(update_fields=['metadata'])
|
||||
return
|
||||
|
||||
# Process retry based on payment method
|
||||
if payment.payment_method == 'stripe':
|
||||
success = _retry_stripe_payment(payment)
|
||||
elif payment.payment_method == 'paypal':
|
||||
success = _retry_paypal_payment(payment)
|
||||
else:
|
||||
# Manual payments cannot be automatically retried
|
||||
logger.info(f"Payment {payment_id} is manual, cannot auto-retry")
|
||||
return
|
||||
|
||||
# Update retry count
|
||||
retry_count += 1
|
||||
payment.metadata['retry_count'] = retry_count
|
||||
payment.metadata['last_retry_at'] = timezone.now().isoformat()
|
||||
|
||||
if success:
|
||||
payment.status = 'succeeded'
|
||||
payment.processed_at = timezone.now()
|
||||
payment.failure_reason = ''
|
||||
logger.info(f"Payment {payment_id} retry succeeded")
|
||||
else:
|
||||
# Schedule next retry with exponential backoff
|
||||
if retry_count < MAX_PAYMENT_RETRIES:
|
||||
delay_minutes = 5 * (2 ** retry_count) # 5, 10, 20 minutes
|
||||
retry_failed_payment.apply_async(
|
||||
args=[payment_id],
|
||||
countdown=delay_minutes * 60
|
||||
)
|
||||
payment.metadata['next_retry_at'] = (
|
||||
timezone.now() + timedelta(minutes=delay_minutes)
|
||||
).isoformat()
|
||||
logger.info(f"Payment {payment_id} retry {retry_count} failed, next retry in {delay_minutes}m")
|
||||
|
||||
payment.save(update_fields=['status', 'processed_at', 'failure_reason', 'metadata'])
|
||||
|
||||
except Payment.DoesNotExist:
|
||||
logger.error(f"Payment {payment_id} not found for retry")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error retrying payment {payment_id}: {str(e)}")
|
||||
|
||||
|
||||
def _retry_stripe_payment(payment: Payment) -> bool:
|
||||
"""
|
||||
Retry Stripe payment
|
||||
|
||||
Args:
|
||||
payment: Payment instance
|
||||
|
||||
Returns:
|
||||
True if retry succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
import stripe
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
|
||||
|
||||
stripe_client = get_stripe_client()
|
||||
|
||||
# Retrieve payment intent
|
||||
intent = stripe_client.PaymentIntent.retrieve(payment.stripe_payment_intent_id)
|
||||
|
||||
# Attempt to confirm the payment intent
|
||||
if intent.status == 'requires_payment_method':
|
||||
# Cannot retry without new payment method
|
||||
payment.failure_reason = 'Requires new payment method'
|
||||
return False
|
||||
elif intent.status == 'requires_action':
|
||||
# Requires customer action (3D Secure)
|
||||
payment.failure_reason = 'Requires customer authentication'
|
||||
return False
|
||||
elif intent.status == 'succeeded':
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stripe retry error for payment {payment.id}: {str(e)}")
|
||||
payment.failure_reason = str(e)
|
||||
return False
|
||||
|
||||
|
||||
def _retry_paypal_payment(payment: Payment) -> bool:
|
||||
"""
|
||||
Retry PayPal payment
|
||||
|
||||
Args:
|
||||
payment: Payment instance
|
||||
|
||||
Returns:
|
||||
True if retry succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
|
||||
|
||||
paypal_client = get_paypal_client()
|
||||
|
||||
# Check order status
|
||||
order = paypal_client.orders.get(payment.paypal_order_id)
|
||||
|
||||
if order.status == 'APPROVED':
|
||||
# Attempt to capture
|
||||
capture_response = paypal_client.orders.capture(payment.paypal_order_id)
|
||||
if capture_response.status == 'COMPLETED':
|
||||
payment.paypal_capture_id = capture_response.purchase_units[0].payments.captures[0].id
|
||||
return True
|
||||
elif order.status == 'COMPLETED':
|
||||
return True
|
||||
|
||||
payment.failure_reason = f'PayPal order status: {order.status}'
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"PayPal retry error for payment {payment.id}: {str(e)}")
|
||||
payment.failure_reason = str(e)
|
||||
return False
|
||||
|
||||
|
||||
@shared_task(name='billing.schedule_payment_retries')
|
||||
def schedule_payment_retries():
|
||||
"""
|
||||
Periodic task to identify failed payments and schedule retries
|
||||
Should be run every 5 minutes via Celery Beat
|
||||
"""
|
||||
# Get failed payments from last 24 hours that haven't exhausted retries
|
||||
cutoff_time = timezone.now() - timedelta(hours=24)
|
||||
|
||||
failed_payments = Payment.objects.filter(
|
||||
status='failed',
|
||||
failed_at__gte=cutoff_time,
|
||||
payment_method__in=['stripe', 'paypal'] # Only auto-retry gateway payments
|
||||
).exclude(
|
||||
metadata__has_key='retry_exhausted'
|
||||
)
|
||||
|
||||
for payment in failed_payments:
|
||||
retry_count = payment.metadata.get('retry_count', 0)
|
||||
if retry_count < MAX_PAYMENT_RETRIES:
|
||||
# Schedule immediate retry if not already scheduled
|
||||
if 'next_retry_at' not in payment.metadata:
|
||||
retry_failed_payment.delay(payment.id)
|
||||
logger.info(f"Scheduled retry for payment {payment.id}")
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Subscription renewal tasks
|
||||
Handles automatic subscription renewals with Celery
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from celery import shared_task
|
||||
from igny8_core.business.billing.models import Subscription, Invoice, Payment
|
||||
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
from igny8_core.business.billing.config import SUBSCRIPTION_RENEWAL_NOTICE_DAYS
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name='billing.send_renewal_notices')
|
||||
def send_renewal_notices():
|
||||
"""
|
||||
Send renewal notice emails to subscribers
|
||||
Run daily to check subscriptions expiring soon
|
||||
"""
|
||||
notice_date = timezone.now().date() + timedelta(days=SUBSCRIPTION_RENEWAL_NOTICE_DAYS)
|
||||
|
||||
# Get active subscriptions expiring on notice_date
|
||||
subscriptions = Subscription.objects.filter(
|
||||
status='active',
|
||||
current_period_end__date=notice_date
|
||||
).select_related('account', 'plan', 'account__owner')
|
||||
|
||||
for subscription in subscriptions:
|
||||
# Check if notice already sent
|
||||
if subscription.metadata.get('renewal_notice_sent'):
|
||||
continue
|
||||
|
||||
try:
|
||||
BillingEmailService.send_subscription_renewal_notice(
|
||||
subscription=subscription,
|
||||
days_until_renewal=SUBSCRIPTION_RENEWAL_NOTICE_DAYS
|
||||
)
|
||||
|
||||
# Mark notice as sent
|
||||
subscription.metadata['renewal_notice_sent'] = True
|
||||
subscription.metadata['renewal_notice_sent_at'] = timezone.now().isoformat()
|
||||
subscription.save(update_fields=['metadata'])
|
||||
|
||||
logger.info(f"Renewal notice sent for subscription {subscription.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send renewal notice for subscription {subscription.id}: {str(e)}")
|
||||
|
||||
|
||||
@shared_task(name='billing.process_subscription_renewals')
|
||||
def process_subscription_renewals():
|
||||
"""
|
||||
Process subscription renewals for subscriptions ending today
|
||||
Run daily at midnight to renew subscriptions
|
||||
"""
|
||||
today = timezone.now().date()
|
||||
|
||||
# Get active subscriptions ending today
|
||||
subscriptions = Subscription.objects.filter(
|
||||
status='active',
|
||||
current_period_end__date=today
|
||||
).select_related('account', 'plan')
|
||||
|
||||
logger.info(f"Processing {subscriptions.count()} subscription renewals for {today}")
|
||||
|
||||
for subscription in subscriptions:
|
||||
try:
|
||||
renew_subscription(subscription.id)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to renew subscription {subscription.id}: {str(e)}")
|
||||
|
||||
|
||||
@shared_task(name='billing.renew_subscription')
|
||||
def renew_subscription(subscription_id: int):
|
||||
"""
|
||||
Renew a specific subscription
|
||||
|
||||
Args:
|
||||
subscription_id: Subscription ID to renew
|
||||
"""
|
||||
try:
|
||||
subscription = Subscription.objects.select_related('account', 'plan').get(id=subscription_id)
|
||||
|
||||
if subscription.status != 'active':
|
||||
logger.warning(f"Subscription {subscription_id} is not active, skipping renewal")
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# Create renewal invoice
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
account=subscription.account,
|
||||
subscription=subscription
|
||||
)
|
||||
|
||||
# Attempt automatic payment if payment method on file
|
||||
payment_attempted = False
|
||||
|
||||
# Check if account has saved payment method
|
||||
if subscription.metadata.get('stripe_subscription_id'):
|
||||
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
|
||||
elif subscription.metadata.get('paypal_subscription_id'):
|
||||
payment_attempted = _attempt_paypal_renewal(subscription, invoice)
|
||||
|
||||
if payment_attempted:
|
||||
# Payment processing will handle subscription renewal
|
||||
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
|
||||
else:
|
||||
# No automatic payment - send invoice for manual payment
|
||||
logger.info(f"Manual payment required for subscription {subscription_id}")
|
||||
|
||||
# Mark subscription as pending renewal
|
||||
subscription.status = 'pending_renewal'
|
||||
subscription.metadata['renewal_invoice_id'] = invoice.id
|
||||
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
|
||||
subscription.save(update_fields=['status', 'metadata'])
|
||||
|
||||
# TODO: Send invoice email
|
||||
|
||||
# Clear renewal notice flag
|
||||
if 'renewal_notice_sent' in subscription.metadata:
|
||||
del subscription.metadata['renewal_notice_sent']
|
||||
subscription.save(update_fields=['metadata'])
|
||||
|
||||
except Subscription.DoesNotExist:
|
||||
logger.error(f"Subscription {subscription_id} not found for renewal")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}")
|
||||
|
||||
|
||||
def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool:
|
||||
"""
|
||||
Attempt to charge Stripe subscription
|
||||
|
||||
Returns:
|
||||
True if payment initiated, False otherwise
|
||||
"""
|
||||
try:
|
||||
import stripe
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
|
||||
|
||||
stripe_client = get_stripe_client()
|
||||
|
||||
# Retrieve Stripe subscription
|
||||
stripe_sub = stripe_client.Subscription.retrieve(
|
||||
subscription.metadata['stripe_subscription_id']
|
||||
)
|
||||
|
||||
# Create payment intent for renewal
|
||||
intent = stripe_client.PaymentIntent.create(
|
||||
amount=int(float(invoice.total_amount) * 100),
|
||||
currency=invoice.currency.lower(),
|
||||
customer=stripe_sub.customer,
|
||||
payment_method=stripe_sub.default_payment_method,
|
||||
off_session=True,
|
||||
confirm=True,
|
||||
metadata={
|
||||
'invoice_id': invoice.id,
|
||||
'subscription_id': subscription.id
|
||||
}
|
||||
)
|
||||
|
||||
# Create payment record
|
||||
Payment.objects.create(
|
||||
account=subscription.account,
|
||||
invoice=invoice,
|
||||
amount=invoice.total_amount,
|
||||
currency=invoice.currency,
|
||||
payment_method='stripe',
|
||||
status='processing',
|
||||
stripe_payment_intent_id=intent.id,
|
||||
metadata={'renewal': True}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stripe renewal payment failed for subscription {subscription.id}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def _attempt_paypal_renewal(subscription: Subscription, invoice: Invoice) -> bool:
|
||||
"""
|
||||
Attempt to charge PayPal subscription
|
||||
|
||||
Returns:
|
||||
True if payment initiated, False otherwise
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
|
||||
|
||||
paypal_client = get_paypal_client()
|
||||
|
||||
# PayPal subscriptions bill automatically
|
||||
# We just need to verify the subscription is still active
|
||||
paypal_sub = paypal_client.subscriptions.get(
|
||||
subscription.metadata['paypal_subscription_id']
|
||||
)
|
||||
|
||||
if paypal_sub.status == 'ACTIVE':
|
||||
# PayPal will charge automatically, create payment record as processing
|
||||
Payment.objects.create(
|
||||
account=subscription.account,
|
||||
invoice=invoice,
|
||||
amount=invoice.total_amount,
|
||||
currency=invoice.currency,
|
||||
payment_method='paypal',
|
||||
status='processing',
|
||||
paypal_order_id=subscription.metadata['paypal_subscription_id'],
|
||||
metadata={'renewal': True}
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"PayPal subscription {paypal_sub.id} status: {paypal_sub.status}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"PayPal renewal check failed for subscription {subscription.id}: {str(e)}")
|
||||
return False
|
||||
299
backend/igny8_core/business/billing/tests/test_concurrency.py
Normal file
299
backend/igny8_core/business/billing/tests/test_concurrency.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Concurrency tests for payment approval
|
||||
Tests race conditions and concurrent approval attempts
|
||||
"""
|
||||
import pytest
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from decimal import Decimal
|
||||
from igny8_core.business.billing.models import (
|
||||
Invoice, Payment, Subscription, Plan, Account
|
||||
)
|
||||
from igny8_core.business.billing.views import approve_payment
|
||||
from unittest.mock import Mock
|
||||
import threading
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PaymentApprovalConcurrencyTest(TransactionTestCase):
|
||||
"""Test concurrent payment approval scenarios"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
# Create admin user
|
||||
self.admin = User.objects.create_user(
|
||||
email='admin@test.com',
|
||||
password='testpass123',
|
||||
is_staff=True
|
||||
)
|
||||
|
||||
# Create account
|
||||
self.account = Account.objects.create(
|
||||
name='Test Account',
|
||||
owner=self.admin,
|
||||
credit_balance=0
|
||||
)
|
||||
|
||||
# Create plan
|
||||
self.plan = Plan.objects.create(
|
||||
name='Test Plan',
|
||||
slug='test-plan',
|
||||
price=Decimal('100.00'),
|
||||
currency='USD',
|
||||
billing_period='monthly',
|
||||
included_credits=1000
|
||||
)
|
||||
|
||||
# Create subscription
|
||||
self.subscription = Subscription.objects.create(
|
||||
account=self.account,
|
||||
plan=self.plan,
|
||||
status='pending_payment'
|
||||
)
|
||||
|
||||
# Create invoice
|
||||
self.invoice = Invoice.objects.create(
|
||||
account=self.account,
|
||||
invoice_number='INV-TEST-001',
|
||||
status='pending',
|
||||
subtotal=Decimal('100.00'),
|
||||
total_amount=Decimal('100.00'),
|
||||
currency='USD',
|
||||
invoice_type='subscription'
|
||||
)
|
||||
|
||||
# Create payment
|
||||
self.payment = Payment.objects.create(
|
||||
account=self.account,
|
||||
invoice=self.invoice,
|
||||
amount=Decimal('100.00'),
|
||||
currency='USD',
|
||||
payment_method='bank_transfer',
|
||||
status='pending_approval',
|
||||
manual_reference='TEST-REF-001'
|
||||
)
|
||||
|
||||
def test_concurrent_approval_attempts(self):
|
||||
"""
|
||||
Test that only one concurrent approval succeeds
|
||||
Multiple admins trying to approve same payment simultaneously
|
||||
"""
|
||||
num_threads = 5
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
results = []
|
||||
|
||||
def approve_payment_thread(payment_id, admin_user):
|
||||
"""Thread worker to approve payment"""
|
||||
try:
|
||||
# Simulate approval logic with transaction
|
||||
with transaction.atomic():
|
||||
payment = Payment.objects.select_for_update().get(id=payment_id)
|
||||
|
||||
# Check if already approved
|
||||
if payment.status == 'succeeded':
|
||||
return {'success': False, 'reason': 'already_approved'}
|
||||
|
||||
# Approve payment
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = admin_user
|
||||
payment.save()
|
||||
|
||||
# Update invoice
|
||||
invoice = payment.invoice
|
||||
invoice.status = 'paid'
|
||||
invoice.save()
|
||||
|
||||
return {'success': True}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
# Create multiple threads attempting approval
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||
futures = []
|
||||
for i in range(num_threads):
|
||||
future = executor.submit(approve_payment_thread, self.payment.id, self.admin)
|
||||
futures.append(future)
|
||||
|
||||
# Collect results
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
if result.get('success'):
|
||||
success_count += 1
|
||||
else:
|
||||
failure_count += 1
|
||||
|
||||
# Verify only one approval succeeded
|
||||
self.assertEqual(success_count, 1, "Only one approval should succeed")
|
||||
self.assertEqual(failure_count, num_threads - 1, "Other attempts should fail")
|
||||
|
||||
# Verify final state
|
||||
payment = Payment.objects.get(id=self.payment.id)
|
||||
self.assertEqual(payment.status, 'succeeded')
|
||||
|
||||
invoice = Invoice.objects.get(id=self.invoice.id)
|
||||
self.assertEqual(invoice.status, 'paid')
|
||||
|
||||
def test_payment_and_invoice_consistency(self):
|
||||
"""
|
||||
Test that payment and invoice remain consistent under concurrent operations
|
||||
"""
|
||||
def read_payment_invoice(payment_id):
|
||||
"""Read payment and invoice status"""
|
||||
payment = Payment.objects.get(id=payment_id)
|
||||
invoice = Invoice.objects.get(id=payment.invoice_id)
|
||||
return {
|
||||
'payment_status': payment.status,
|
||||
'invoice_status': invoice.status,
|
||||
'consistent': (
|
||||
(payment.status == 'succeeded' and invoice.status == 'paid') or
|
||||
(payment.status == 'pending_approval' and invoice.status == 'pending')
|
||||
)
|
||||
}
|
||||
|
||||
# Approve payment in one thread
|
||||
def approve():
|
||||
with transaction.atomic():
|
||||
payment = Payment.objects.select_for_update().get(id=self.payment.id)
|
||||
payment.status = 'succeeded'
|
||||
payment.save()
|
||||
|
||||
invoice = Invoice.objects.select_for_update().get(id=self.invoice.id)
|
||||
invoice.status = 'paid'
|
||||
invoice.save()
|
||||
|
||||
# Read state in parallel threads
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
# Start approval
|
||||
approval_future = executor.submit(approve)
|
||||
|
||||
# Multiple concurrent reads
|
||||
read_futures = [
|
||||
executor.submit(read_payment_invoice, self.payment.id)
|
||||
for _ in range(20)
|
||||
]
|
||||
|
||||
# Wait for approval
|
||||
approval_future.result()
|
||||
|
||||
# Collect read results
|
||||
for future in as_completed(read_futures):
|
||||
results.append(future.result())
|
||||
|
||||
# All reads should show consistent state
|
||||
for result in results:
|
||||
self.assertTrue(
|
||||
result['consistent'],
|
||||
f"Inconsistent state: payment={result['payment_status']}, invoice={result['invoice_status']}"
|
||||
)
|
||||
|
||||
def test_double_approval_prevention(self):
|
||||
"""
|
||||
Test that payment cannot be approved twice
|
||||
"""
|
||||
# First approval
|
||||
with transaction.atomic():
|
||||
payment = Payment.objects.select_for_update().get(id=self.payment.id)
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = self.admin
|
||||
payment.save()
|
||||
|
||||
invoice = payment.invoice
|
||||
invoice.status = 'paid'
|
||||
invoice.save()
|
||||
|
||||
# Attempt second approval
|
||||
result = None
|
||||
try:
|
||||
with transaction.atomic():
|
||||
payment = Payment.objects.select_for_update().get(id=self.payment.id)
|
||||
|
||||
# Should detect already approved
|
||||
if payment.status == 'succeeded':
|
||||
result = 'already_approved'
|
||||
else:
|
||||
payment.status = 'succeeded'
|
||||
payment.save()
|
||||
result = 'approved'
|
||||
except Exception as e:
|
||||
result = f'error: {str(e)}'
|
||||
|
||||
self.assertEqual(result, 'already_approved', "Second approval should be prevented")
|
||||
|
||||
|
||||
class CreditTransactionConcurrencyTest(TransactionTestCase):
|
||||
"""Test concurrent credit additions/deductions"""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(
|
||||
email='admin@test.com',
|
||||
password='testpass123'
|
||||
)
|
||||
self.account = Account.objects.create(
|
||||
name='Test Account',
|
||||
owner=self.admin,
|
||||
credit_balance=1000
|
||||
)
|
||||
|
||||
def test_concurrent_credit_deductions(self):
|
||||
"""
|
||||
Test that concurrent credit deductions maintain correct balance
|
||||
"""
|
||||
initial_balance = self.account.credit_balance
|
||||
deduction_amount = 10
|
||||
num_operations = 20
|
||||
|
||||
def deduct_credits(account_id, amount):
|
||||
"""Deduct credits atomically"""
|
||||
from igny8_core.business.billing.models import CreditTransaction
|
||||
|
||||
with transaction.atomic():
|
||||
account = Account.objects.select_for_update().get(id=account_id)
|
||||
|
||||
# Check sufficient balance
|
||||
if account.credit_balance < amount:
|
||||
return {'success': False, 'reason': 'insufficient_credits'}
|
||||
|
||||
# Deduct credits
|
||||
account.credit_balance -= amount
|
||||
new_balance = account.credit_balance
|
||||
account.save()
|
||||
|
||||
# Record transaction
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type='deduction',
|
||||
amount=-amount,
|
||||
balance_after=new_balance,
|
||||
description='Test deduction'
|
||||
)
|
||||
|
||||
return {'success': True, 'new_balance': new_balance}
|
||||
|
||||
# Concurrent deductions
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
futures = [
|
||||
executor.submit(deduct_credits, self.account.id, deduction_amount)
|
||||
for _ in range(num_operations)
|
||||
]
|
||||
|
||||
results = [future.result() for future in as_completed(futures)]
|
||||
|
||||
# Verify all succeeded
|
||||
success_count = sum(1 for r in results if r.get('success'))
|
||||
self.assertEqual(success_count, num_operations, "All deductions should succeed")
|
||||
|
||||
# Verify final balance
|
||||
self.account.refresh_from_db()
|
||||
expected_balance = initial_balance - (deduction_amount * num_operations)
|
||||
self.assertEqual(
|
||||
self.account.credit_balance,
|
||||
expected_balance,
|
||||
f"Final balance should be {expected_balance}"
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Test payment method filtering by country
|
||||
"""
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from igny8_core.business.billing.models import PaymentMethodConfig
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PaymentMethodFilteringTest(TestCase):
|
||||
"""Test payment method filtering by billing country"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create test payment method configs"""
|
||||
# Global methods (available everywhere)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='*',
|
||||
payment_method='stripe',
|
||||
display_name='Credit/Debit Card',
|
||||
is_enabled=True,
|
||||
sort_order=1,
|
||||
)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='*',
|
||||
payment_method='paypal',
|
||||
display_name='PayPal',
|
||||
is_enabled=True,
|
||||
sort_order=2,
|
||||
)
|
||||
|
||||
# Country-specific methods
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='GB',
|
||||
payment_method='bank_transfer',
|
||||
display_name='Bank Transfer (UK)',
|
||||
is_enabled=True,
|
||||
sort_order=3,
|
||||
)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='IN',
|
||||
payment_method='local_wallet',
|
||||
display_name='UPI/Wallets',
|
||||
is_enabled=True,
|
||||
sort_order=4,
|
||||
)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='PK',
|
||||
payment_method='bank_transfer',
|
||||
display_name='Bank Transfer (Pakistan)',
|
||||
is_enabled=True,
|
||||
sort_order=5,
|
||||
)
|
||||
|
||||
# Disabled method (should not appear)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='*',
|
||||
payment_method='manual',
|
||||
display_name='Manual',
|
||||
is_enabled=False,
|
||||
sort_order=99,
|
||||
)
|
||||
|
||||
self.client = Client()
|
||||
|
||||
def test_filter_payment_methods_by_us(self):
|
||||
"""Test filtering for US country - should get only global methods"""
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=US')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(len(data['results']), 2) # Only stripe and paypal
|
||||
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertIn('stripe', methods)
|
||||
self.assertIn('paypal', methods)
|
||||
|
||||
def test_filter_payment_methods_by_gb(self):
|
||||
"""Test filtering for GB - should get global + GB-specific"""
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=GB')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(len(data['results']), 3) # stripe, paypal, bank_transfer(GB)
|
||||
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertIn('stripe', methods)
|
||||
self.assertIn('paypal', methods)
|
||||
self.assertIn('bank_transfer', methods)
|
||||
|
||||
def test_filter_payment_methods_by_in(self):
|
||||
"""Test filtering for IN - should get global + IN-specific"""
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=IN')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(len(data['results']), 3) # stripe, paypal, local_wallet(IN)
|
||||
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertIn('stripe', methods)
|
||||
self.assertIn('paypal', methods)
|
||||
self.assertIn('local_wallet', methods)
|
||||
|
||||
def test_disabled_methods_not_returned(self):
|
||||
"""Test that disabled payment methods are not included"""
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=*')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertNotIn('manual', methods) # Disabled method should not appear
|
||||
|
||||
def test_sort_order_respected(self):
|
||||
\"\"\"Test that payment methods are returned in sort_order\"\"\"
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=GB')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
# Verify first method has lowest sort_order
|
||||
self.assertEqual(data['results'][0]['type'], 'stripe')
|
||||
self.assertEqual(data['results'][0]['sort_order'], 1)
|
||||
|
||||
def test_default_country_fallback(self):
|
||||
"""Test that missing country parameter defaults to global (*)\"\"\"\n response = self.client.get('/api/v1/billing/admin/payment-methods/')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertTrue(data['success'])
|
||||
# Should get at least global methods
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertIn('stripe', methods)
|
||||
self.assertIn('paypal', methods)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Integration tests for payment workflow
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
from datetime import timedelta
|
||||
|
||||
from igny8_core.auth.models import Account, Plan, Subscription
|
||||
from igny8_core.business.billing.models import Invoice, Payment
|
||||
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PaymentWorkflowIntegrationTest(TestCase):
|
||||
"""Test complete payment workflow including invoice.subscription FK"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create test data"""
|
||||
# Create plan
|
||||
self.plan = Plan.objects.create(
|
||||
name='Test Plan',
|
||||
slug='test-plan',
|
||||
price=Decimal('29.00'),
|
||||
included_credits=1000,
|
||||
max_sites=5,
|
||||
)
|
||||
|
||||
# Create account
|
||||
self.account = Account.objects.create(
|
||||
name='Test Account',
|
||||
slug='test-account',
|
||||
status='pending_payment',
|
||||
billing_country='US',
|
||||
billing_email='test@example.com',
|
||||
)
|
||||
|
||||
# Create user
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='testuser@example.com',
|
||||
password='testpass123',
|
||||
account=self.account,
|
||||
)
|
||||
|
||||
# Create subscription
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
self.subscription = Subscription.objects.create(
|
||||
account=self.account,
|
||||
plan=self.plan,
|
||||
status='pending_payment',
|
||||
current_period_start=billing_period_start,
|
||||
current_period_end=billing_period_end,
|
||||
)
|
||||
|
||||
def test_invoice_subscription_fk_relationship(self):
|
||||
"""Test that invoice.subscription FK works correctly"""
|
||||
# Create invoice via service
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
|
||||
# Verify FK relationship
|
||||
self.assertIsNotNone(invoice.subscription)
|
||||
self.assertEqual(invoice.subscription.id, self.subscription.id)
|
||||
self.assertEqual(invoice.subscription.plan.id, self.plan.id)
|
||||
|
||||
# Verify can access subscription from invoice
|
||||
self.assertEqual(invoice.subscription.account, self.account)
|
||||
self.assertEqual(invoice.subscription.plan.name, 'Test Plan')
|
||||
|
||||
def test_payment_approval_with_subscription(self):
|
||||
"""Test payment approval workflow uses invoice.subscription"""
|
||||
# Create invoice
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
|
||||
# Create payment
|
||||
payment = Payment.objects.create(
|
||||
account=self.account,
|
||||
invoice=invoice,
|
||||
amount=invoice.total,
|
||||
currency='USD',
|
||||
status='pending_approval',
|
||||
payment_method='bank_transfer',
|
||||
manual_reference='TEST-REF-001',
|
||||
)
|
||||
|
||||
# Verify payment links to invoice which links to subscription
|
||||
self.assertIsNotNone(payment.invoice)
|
||||
self.assertIsNotNone(payment.invoice.subscription)
|
||||
self.assertEqual(payment.invoice.subscription.id, self.subscription.id)
|
||||
|
||||
# Simulate approval workflow
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = self.user
|
||||
payment.approved_at = timezone.now()
|
||||
payment.save()
|
||||
|
||||
# Update related records
|
||||
invoice.status = 'paid'
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save()
|
||||
|
||||
subscription = invoice.subscription
|
||||
subscription.status = 'active'
|
||||
subscription.save()
|
||||
|
||||
# Verify workflow completed successfully
|
||||
self.assertEqual(payment.status, 'succeeded')
|
||||
self.assertEqual(invoice.status, 'paid')
|
||||
self.assertEqual(subscription.status, 'active')
|
||||
self.assertEqual(subscription.plan.included_credits, 1000)
|
||||
|
||||
def test_subscription_dates_not_null_for_paid_plans(self):
|
||||
"""Test that subscription dates are set for paid plans"""
|
||||
self.assertIsNotNone(self.subscription.current_period_start)
|
||||
self.assertIsNotNone(self.subscription.current_period_end)
|
||||
|
||||
# Verify dates are in future
|
||||
self.assertGreater(self.subscription.current_period_end, self.subscription.current_period_start)
|
||||
|
||||
def test_invoice_currency_based_on_country(self):
|
||||
"""Test that invoice currency is set based on billing country"""
|
||||
# Test US -> USD
|
||||
self.account.billing_country = 'US'
|
||||
self.account.save()
|
||||
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
invoice_us = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
self.assertEqual(invoice_us.currency, 'USD')
|
||||
|
||||
# Test GB -> GBP
|
||||
self.account.billing_country = 'GB'
|
||||
self.account.save()
|
||||
|
||||
invoice_gb = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
self.assertEqual(invoice_gb.currency, 'GBP')
|
||||
|
||||
# Test IN -> INR
|
||||
self.account.billing_country = 'IN'
|
||||
self.account.save()
|
||||
|
||||
invoice_in = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
self.assertEqual(invoice_in.currency, 'INR')
|
||||
|
||||
def test_invoice_due_date_grace_period(self):
|
||||
"""Test that invoice due date uses grace period instead of billing_period_end"""
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
|
||||
# Verify due date is invoice_date + 7 days (grace period)
|
||||
expected_due_date = invoice.invoice_date + timedelta(days=7)
|
||||
self.assertEqual(invoice.due_date, expected_due_date)
|
||||
|
||||
# Verify it's NOT billing_period_end
|
||||
self.assertNotEqual(invoice.due_date, billing_period_end.date())
|
||||
213
backend/igny8_core/business/billing/utils/currency.py
Normal file
213
backend/igny8_core/business/billing/utils/currency.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Currency utilities for billing
|
||||
Maps countries to their currencies based on Stripe/PayPal standards
|
||||
"""
|
||||
|
||||
# Country to currency mapping (Stripe/PayPal standard format)
|
||||
COUNTRY_CURRENCY_MAP = {
|
||||
# North America
|
||||
'US': 'USD',
|
||||
'CA': 'CAD',
|
||||
'MX': 'MXN',
|
||||
|
||||
# Europe
|
||||
'GB': 'GBP',
|
||||
'DE': 'EUR',
|
||||
'FR': 'EUR',
|
||||
'IT': 'EUR',
|
||||
'ES': 'EUR',
|
||||
'NL': 'EUR',
|
||||
'BE': 'EUR',
|
||||
'AT': 'EUR',
|
||||
'PT': 'EUR',
|
||||
'IE': 'EUR',
|
||||
'GR': 'EUR',
|
||||
'FI': 'EUR',
|
||||
'LU': 'EUR',
|
||||
'CH': 'CHF',
|
||||
'NO': 'NOK',
|
||||
'SE': 'SEK',
|
||||
'DK': 'DKK',
|
||||
'PL': 'PLN',
|
||||
'CZ': 'CZK',
|
||||
'HU': 'HUF',
|
||||
'RO': 'RON',
|
||||
|
||||
# Asia Pacific
|
||||
'IN': 'INR',
|
||||
'PK': 'PKR',
|
||||
'BD': 'BDT',
|
||||
'LK': 'LKR',
|
||||
'JP': 'JPY',
|
||||
'CN': 'CNY',
|
||||
'HK': 'HKD',
|
||||
'SG': 'SGD',
|
||||
'MY': 'MYR',
|
||||
'TH': 'THB',
|
||||
'ID': 'IDR',
|
||||
'PH': 'PHP',
|
||||
'VN': 'VND',
|
||||
'KR': 'KRW',
|
||||
'TW': 'TWD',
|
||||
'AU': 'AUD',
|
||||
'NZ': 'NZD',
|
||||
|
||||
# Middle East
|
||||
'AE': 'AED',
|
||||
'SA': 'SAR',
|
||||
'QA': 'QAR',
|
||||
'KW': 'KWD',
|
||||
'BH': 'BHD',
|
||||
'OM': 'OMR',
|
||||
'IL': 'ILS',
|
||||
'TR': 'TRY',
|
||||
|
||||
# Africa
|
||||
'ZA': 'ZAR',
|
||||
'NG': 'NGN',
|
||||
'KE': 'KES',
|
||||
'EG': 'EGP',
|
||||
'MA': 'MAD',
|
||||
|
||||
# South America
|
||||
'BR': 'BRL',
|
||||
'AR': 'ARS',
|
||||
'CL': 'CLP',
|
||||
'CO': 'COP',
|
||||
'PE': 'PEN',
|
||||
}
|
||||
|
||||
# Default currency fallback
|
||||
DEFAULT_CURRENCY = 'USD'
|
||||
|
||||
|
||||
def get_currency_for_country(country_code: str) -> str:
|
||||
"""
|
||||
Get currency code for a given country code.
|
||||
|
||||
Args:
|
||||
country_code: ISO 2-letter country code (e.g., 'US', 'GB', 'IN')
|
||||
|
||||
Returns:
|
||||
Currency code (e.g., 'USD', 'GBP', 'INR')
|
||||
"""
|
||||
if not country_code:
|
||||
return DEFAULT_CURRENCY
|
||||
|
||||
country_code = country_code.upper().strip()
|
||||
return COUNTRY_CURRENCY_MAP.get(country_code, DEFAULT_CURRENCY)
|
||||
|
||||
|
||||
def get_currency_symbol(currency_code: str) -> str:
|
||||
"""
|
||||
Get currency symbol for a given currency code.
|
||||
|
||||
Args:
|
||||
currency_code: Currency code (e.g., 'USD', 'GBP', 'INR')
|
||||
|
||||
Returns:
|
||||
Currency symbol (e.g., '$', '£', '₹')
|
||||
"""
|
||||
CURRENCY_SYMBOLS = {
|
||||
'USD': '$',
|
||||
'EUR': '€',
|
||||
'GBP': '£',
|
||||
'INR': '₹',
|
||||
'JPY': '¥',
|
||||
'CNY': '¥',
|
||||
'AUD': 'A$',
|
||||
'CAD': 'C$',
|
||||
'CHF': 'Fr',
|
||||
'SEK': 'kr',
|
||||
'NOK': 'kr',
|
||||
'DKK': 'kr',
|
||||
'PLN': 'zł',
|
||||
'BRL': 'R$',
|
||||
'ZAR': 'R',
|
||||
'AED': 'د.إ',
|
||||
'SAR': 'ر.س',
|
||||
'PKR': '₨',
|
||||
}
|
||||
|
||||
return CURRENCY_SYMBOLS.get(currency_code, currency_code + ' ')
|
||||
|
||||
|
||||
# Currency multipliers for countries with payment methods configured
|
||||
# These represent approximate exchange rates to USD
|
||||
CURRENCY_MULTIPLIERS = {
|
||||
'USD': 1.0, # United States
|
||||
'GBP': 0.79, # United Kingdom
|
||||
'INR': 83.0, # India
|
||||
'PKR': 278.0, # Pakistan
|
||||
'CAD': 1.35, # Canada
|
||||
'AUD': 1.52, # Australia
|
||||
'EUR': 0.92, # Germany, France (Eurozone)
|
||||
}
|
||||
|
||||
# Map countries to their multipliers
|
||||
COUNTRY_MULTIPLIERS = {
|
||||
'US': CURRENCY_MULTIPLIERS['USD'],
|
||||
'GB': CURRENCY_MULTIPLIERS['GBP'],
|
||||
'IN': CURRENCY_MULTIPLIERS['INR'],
|
||||
'PK': CURRENCY_MULTIPLIERS['PKR'],
|
||||
'CA': CURRENCY_MULTIPLIERS['CAD'],
|
||||
'AU': CURRENCY_MULTIPLIERS['AUD'],
|
||||
'DE': CURRENCY_MULTIPLIERS['EUR'],
|
||||
'FR': CURRENCY_MULTIPLIERS['EUR'],
|
||||
}
|
||||
|
||||
|
||||
def get_currency_multiplier(country_code: str) -> float:
|
||||
"""
|
||||
Get currency multiplier for a given country code.
|
||||
Used to convert USD prices to local currency.
|
||||
|
||||
Args:
|
||||
country_code: ISO 2-letter country code (e.g., 'US', 'GB', 'IN')
|
||||
|
||||
Returns:
|
||||
Multiplier float (e.g., 1.0 for USD, 83.0 for INR)
|
||||
"""
|
||||
if not country_code:
|
||||
return 1.0
|
||||
|
||||
country_code = country_code.upper().strip()
|
||||
return COUNTRY_MULTIPLIERS.get(country_code, 1.0)
|
||||
|
||||
|
||||
def convert_usd_to_local(usd_amount: float, country_code: str) -> float:
|
||||
"""
|
||||
Convert USD amount to local currency for given country.
|
||||
|
||||
Args:
|
||||
usd_amount: Amount in USD
|
||||
country_code: ISO 2-letter country code
|
||||
|
||||
Returns:
|
||||
Amount in local currency
|
||||
"""
|
||||
multiplier = get_currency_multiplier(country_code)
|
||||
return round(usd_amount * multiplier, 2)
|
||||
|
||||
|
||||
def format_currency(amount: float, country_code: str = 'US') -> str:
|
||||
"""
|
||||
Format amount with appropriate currency symbol.
|
||||
|
||||
Args:
|
||||
amount: Numeric amount
|
||||
country_code: ISO 2-letter country code
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., '$99.00', '₹8,300.00')
|
||||
"""
|
||||
currency = get_currency_for_country(country_code)
|
||||
symbol = get_currency_symbol(currency)
|
||||
|
||||
# Format with commas for thousands
|
||||
if amount >= 1000:
|
||||
formatted = f"{amount:,.2f}"
|
||||
else:
|
||||
formatted = f"{amount:.2f}"
|
||||
|
||||
return f"{symbol}{formatted}"
|
||||
186
backend/igny8_core/business/billing/utils/errors.py
Normal file
186
backend/igny8_core/business/billing/utils/errors.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Standardized Error Response Utilities
|
||||
Ensures consistent error formats across the billing module
|
||||
"""
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
|
||||
class ErrorCode:
|
||||
"""Standardized error codes for billing module"""
|
||||
# Payment errors
|
||||
PAYMENT_NOT_FOUND = 'payment_not_found'
|
||||
PAYMENT_ALREADY_PROCESSED = 'payment_already_processed'
|
||||
PAYMENT_AMOUNT_MISMATCH = 'payment_amount_mismatch'
|
||||
PAYMENT_METHOD_NOT_AVAILABLE = 'payment_method_not_available'
|
||||
|
||||
# Invoice errors
|
||||
INVOICE_NOT_FOUND = 'invoice_not_found'
|
||||
INVOICE_ALREADY_PAID = 'invoice_already_paid'
|
||||
INVOICE_VOIDED = 'invoice_voided'
|
||||
INVOICE_EXPIRED = 'invoice_expired'
|
||||
|
||||
# Subscription errors
|
||||
SUBSCRIPTION_NOT_FOUND = 'subscription_not_found'
|
||||
SUBSCRIPTION_INACTIVE = 'subscription_inactive'
|
||||
SUBSCRIPTION_ALREADY_EXISTS = 'subscription_already_exists'
|
||||
|
||||
# Credit errors
|
||||
INSUFFICIENT_CREDITS = 'insufficient_credits'
|
||||
INVALID_CREDIT_PACKAGE = 'invalid_credit_package'
|
||||
|
||||
# Validation errors
|
||||
VALIDATION_ERROR = 'validation_error'
|
||||
MISSING_REQUIRED_FIELD = 'missing_required_field'
|
||||
INVALID_STATUS_TRANSITION = 'invalid_status_transition'
|
||||
|
||||
# Authorization errors
|
||||
UNAUTHORIZED = 'unauthorized'
|
||||
FORBIDDEN = 'forbidden'
|
||||
|
||||
# System errors
|
||||
INTERNAL_ERROR = 'internal_error'
|
||||
TIMEOUT = 'timeout'
|
||||
RATE_LIMITED = 'rate_limited'
|
||||
|
||||
|
||||
def error_response(
|
||||
message: str,
|
||||
code: str = ErrorCode.INTERNAL_ERROR,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
field_errors: Optional[Dict[str, List[str]]] = None,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST
|
||||
) -> Response:
|
||||
"""
|
||||
Create standardized error response
|
||||
|
||||
Args:
|
||||
message: Human-readable error message
|
||||
code: Error code from ErrorCode class
|
||||
details: Additional error context
|
||||
field_errors: Field-specific validation errors
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
DRF Response with standardized error format
|
||||
"""
|
||||
response_data = {
|
||||
'success': False,
|
||||
'error': {
|
||||
'code': code,
|
||||
'message': message,
|
||||
}
|
||||
}
|
||||
|
||||
if details:
|
||||
response_data['error']['details'] = details
|
||||
|
||||
if field_errors:
|
||||
response_data['error']['field_errors'] = field_errors
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
|
||||
|
||||
def success_response(
|
||||
data: Any = None,
|
||||
message: Optional[str] = None,
|
||||
status_code: int = status.HTTP_200_OK
|
||||
) -> Response:
|
||||
"""
|
||||
Create standardized success response
|
||||
|
||||
Args:
|
||||
data: Response data
|
||||
message: Optional success message
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
DRF Response with standardized success format
|
||||
"""
|
||||
response_data = {
|
||||
'success': True,
|
||||
}
|
||||
|
||||
if message:
|
||||
response_data['message'] = message
|
||||
|
||||
if data is not None:
|
||||
response_data['data'] = data
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
|
||||
|
||||
def validation_error_response(
|
||||
field_errors: Dict[str, List[str]],
|
||||
message: str = 'Validation failed'
|
||||
) -> Response:
|
||||
"""
|
||||
Create validation error response
|
||||
|
||||
Args:
|
||||
field_errors: Dictionary mapping field names to error messages
|
||||
message: General validation error message
|
||||
|
||||
Returns:
|
||||
DRF Response with validation error format
|
||||
"""
|
||||
return error_response(
|
||||
message=message,
|
||||
code=ErrorCode.VALIDATION_ERROR,
|
||||
field_errors=field_errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
def not_found_response(
|
||||
resource: str,
|
||||
identifier: Any = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create not found error response
|
||||
|
||||
Args:
|
||||
resource: Resource type (e.g., 'Payment', 'Invoice')
|
||||
identifier: Resource identifier (ID, slug, etc.)
|
||||
|
||||
Returns:
|
||||
DRF Response with not found error
|
||||
"""
|
||||
message = f"{resource} not found"
|
||||
if identifier:
|
||||
message += f": {identifier}"
|
||||
|
||||
code_map = {
|
||||
'Payment': ErrorCode.PAYMENT_NOT_FOUND,
|
||||
'Invoice': ErrorCode.INVOICE_NOT_FOUND,
|
||||
'Subscription': ErrorCode.SUBSCRIPTION_NOT_FOUND,
|
||||
}
|
||||
|
||||
return error_response(
|
||||
message=message,
|
||||
code=code_map.get(resource, ErrorCode.INTERNAL_ERROR),
|
||||
status_code=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
def unauthorized_response(
|
||||
message: str = 'Authentication required'
|
||||
) -> Response:
|
||||
"""Create unauthorized error response"""
|
||||
return error_response(
|
||||
message=message,
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
|
||||
def forbidden_response(
|
||||
message: str = 'You do not have permission to perform this action'
|
||||
) -> Response:
|
||||
"""Create forbidden error response"""
|
||||
return error_response(
|
||||
message=message,
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
status_code=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
@@ -33,6 +33,17 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
permission_classes = [IsAdminOrOwner]
|
||||
|
||||
def get_permissions(self):
|
||||
"""
|
||||
Allow action-level permissions to override class-level permissions.
|
||||
"""
|
||||
# Try to get permission_classes from the action
|
||||
try:
|
||||
# DRF stores action permission_classes in the view method
|
||||
return [permission() for permission in self.permission_classes]
|
||||
except Exception:
|
||||
return super().get_permissions()
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='confirm-bank-transfer')
|
||||
def confirm_bank_transfer(self, request):
|
||||
"""
|
||||
@@ -182,22 +193,30 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
def list_payment_methods(self, request):
|
||||
"""
|
||||
Get available payment methods for a specific country.
|
||||
Public endpoint - only returns enabled payment methods.
|
||||
Does not expose sensitive configuration details.
|
||||
|
||||
Query params:
|
||||
country: ISO 2-letter country code (default: '*' for global)
|
||||
country: ISO 2-letter country code (default: 'US')
|
||||
|
||||
Returns payment methods filtered by country (country-specific + global).
|
||||
Returns payment methods filtered by country.
|
||||
"""
|
||||
country = request.GET.get('country', '*').upper()
|
||||
country = request.GET.get('country', 'US').upper()
|
||||
|
||||
# Get country-specific + global methods
|
||||
# Get country-specific methods
|
||||
methods = PaymentMethodConfig.objects.filter(
|
||||
Q(country_code=country) | Q(country_code='*'),
|
||||
country_code=country,
|
||||
is_enabled=True
|
||||
).order_by('sort_order')
|
||||
|
||||
# Serialize using the proper serializer
|
||||
serializer = PaymentMethodConfigSerializer(methods, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# Return in consistent format
|
||||
return Response({
|
||||
'success': True,
|
||||
'results': serializer.data
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='payments/confirm', permission_classes=[IsAuthenticatedAndActive])
|
||||
def confirm_payment(self, request):
|
||||
@@ -237,6 +256,26 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
account=request.account
|
||||
)
|
||||
|
||||
# Check if payment already exists for this invoice
|
||||
existing_payment = Payment.objects.filter(
|
||||
invoice=invoice,
|
||||
status__in=['pending_approval', 'succeeded']
|
||||
).first()
|
||||
|
||||
if existing_payment:
|
||||
if existing_payment.status == 'succeeded':
|
||||
return error_response(
|
||||
error='This invoice has already been paid and approved.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return error_response(
|
||||
error=f'A payment confirmation is already pending approval for this invoice (Payment ID: {existing_payment.id}).',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Validate amount matches invoice
|
||||
if amount != invoice.total:
|
||||
return error_response(
|
||||
@@ -264,8 +303,12 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
f'Reference: {manual_reference}'
|
||||
)
|
||||
|
||||
# TODO: Send notification to admin
|
||||
# send_payment_confirmation_notification(payment)
|
||||
# Send email notification to user
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_payment_confirmation_email(payment, request.account)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment confirmation email: {str(e)}')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -283,14 +326,20 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
except Invoice.DoesNotExist:
|
||||
return error_response(
|
||||
error='Invoice not found or does not belong to your account',
|
||||
error='Invoice not found. Please check the invoice ID or contact support.',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except ValueError as ve:
|
||||
return error_response(
|
||||
error=f'Invalid amount format: {str(ve)}',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error confirming payment: {str(e)}', exc_info=True)
|
||||
return error_response(
|
||||
error=f'Failed to submit payment confirmation: {str(e)}',
|
||||
error='An unexpected error occurred while processing your payment confirmation. Please try again or contact support.',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
@@ -310,25 +359,66 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Get payment with related objects
|
||||
# Get payment with all related objects to prevent N+1 queries
|
||||
payment = Payment.objects.select_related(
|
||||
'invoice',
|
||||
'invoice__subscription',
|
||||
'invoice__subscription__plan',
|
||||
'account'
|
||||
'account',
|
||||
'account__subscription',
|
||||
'account__subscription__plan',
|
||||
'account__plan'
|
||||
).get(id=pk)
|
||||
|
||||
if payment.status != 'pending_approval':
|
||||
status_msg = {
|
||||
'succeeded': 'This payment has already been approved and processed',
|
||||
'failed': 'This payment was previously rejected and cannot be approved',
|
||||
'refunded': 'This payment was refunded and cannot be re-approved'
|
||||
}.get(payment.status, f'Payment has invalid status: {payment.status}')
|
||||
return error_response(
|
||||
error=f'Payment is not pending approval (current status: {payment.status})',
|
||||
error=status_msg,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
invoice = payment.invoice
|
||||
subscription = invoice.subscription
|
||||
account = payment.account
|
||||
|
||||
# Validate invoice is still pending
|
||||
if invoice.status == 'paid':
|
||||
return error_response(
|
||||
error='Invoice is already marked as paid. Payment cannot be approved again.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Validate invoice is not void
|
||||
if invoice.status == 'void':
|
||||
return error_response(
|
||||
error='Invoice has been voided. Payment cannot be approved for a void invoice.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Validate amount matches
|
||||
if payment.amount != invoice.total:
|
||||
return error_response(
|
||||
error=f'Payment amount ({payment.currency} {payment.amount}) does not match invoice total ({invoice.currency} {invoice.total}). Please verify the payment.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get subscription from invoice first, fallback to account.subscription
|
||||
subscription = None
|
||||
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
|
||||
subscription = invoice.subscription
|
||||
elif account and hasattr(account, 'subscription'):
|
||||
try:
|
||||
subscription = account.subscription
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 1. Update Payment
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = request.user
|
||||
@@ -354,7 +444,8 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
# 5. Add Credits (if subscription has plan)
|
||||
credits_added = 0
|
||||
if subscription and subscription.plan:
|
||||
try:
|
||||
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
||||
credits_added = subscription.plan.included_credits
|
||||
|
||||
# Use CreditService to add credits
|
||||
@@ -371,14 +462,38 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
elif account and account.plan and account.plan.included_credits > 0:
|
||||
# Fallback: use account plan if subscription not found
|
||||
credits_added = account.plan.included_credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_added,
|
||||
transaction_type='subscription',
|
||||
description=f'{account.plan.name} plan credits - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': payment.id,
|
||||
'plan_id': account.plan.id,
|
||||
'approved_by': request.user.email,
|
||||
'fallback': 'account_plan'
|
||||
}
|
||||
)
|
||||
except Exception as credit_error:
|
||||
# Rollback payment approval if credit addition fails
|
||||
logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True)
|
||||
raise Exception(f'Failed to add credits: {str(credit_error)}') from credit_error
|
||||
|
||||
logger.info(
|
||||
f'Payment approved: Payment {payment.id}, Invoice {invoice.invoice_number}, '
|
||||
f'Account {account.id} activated, {credits_added} credits added'
|
||||
)
|
||||
|
||||
# TODO: Send activation email to user
|
||||
# send_account_activated_email(account, subscription)
|
||||
# Send activation email to user
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_payment_approved_email(payment, account, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment approved email: {str(e)}')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -399,14 +514,24 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
except Payment.DoesNotExist:
|
||||
return error_response(
|
||||
error='Payment not found',
|
||||
error='Payment not found. The payment may have been deleted or the ID is incorrect.',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error approving payment: {str(e)}', exc_info=True)
|
||||
logger.error(f'Error approving payment {pk}: {str(e)}', exc_info=True)
|
||||
|
||||
# Provide specific error messages
|
||||
error_msg = str(e)
|
||||
if 'credit' in error_msg.lower():
|
||||
error_msg = 'Failed to add credits to account. Payment not approved. Please check the plan configuration.'
|
||||
elif 'subscription' in error_msg.lower():
|
||||
error_msg = 'Failed to activate subscription. Payment not approved. Please verify subscription exists.'
|
||||
else:
|
||||
error_msg = f'Payment approval failed: {error_msg}'
|
||||
|
||||
return error_response(
|
||||
error=f'Failed to approve payment: {str(e)}',
|
||||
error=error_msg,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
@@ -441,10 +566,20 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
payment.failure_reason = admin_notes
|
||||
payment.save(update_fields=['status', 'approved_by', 'approved_at', 'failed_at', 'admin_notes', 'failure_reason'])
|
||||
|
||||
# Update account status to allow retry
|
||||
account = payment.account
|
||||
if account.status != 'active':
|
||||
account.status = 'pending_payment'
|
||||
account.save(update_fields=['status'])
|
||||
|
||||
logger.info(f'Payment rejected: Payment {payment.id}, Reason: {admin_notes}')
|
||||
|
||||
# TODO: Send rejection email to user
|
||||
# send_payment_rejected_email(payment)
|
||||
# Send rejection email to user
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_payment_rejected_email(payment, account, admin_notes)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment rejected email: {str(e)}')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -459,14 +594,14 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
except Payment.DoesNotExist:
|
||||
return error_response(
|
||||
error='Payment not found',
|
||||
error='Payment not found. The payment may have been deleted or the ID is incorrect.',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error rejecting payment: {str(e)}', exc_info=True)
|
||||
logger.error(f'Error rejecting payment {pk}: {str(e)}', exc_info=True)
|
||||
return error_response(
|
||||
error=f'Failed to reject payment: {str(e)}',
|
||||
error=f'Failed to reject payment. Please try again or contact technical support.',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
@@ -504,6 +639,7 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
'id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'status': invoice.status,
|
||||
'total': str(invoice.total), # Alias for compatibility
|
||||
'total_amount': str(invoice.total),
|
||||
'subtotal': str(invoice.subtotal),
|
||||
'tax_amount': str(invoice.tax),
|
||||
@@ -530,6 +666,7 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
'id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'status': invoice.status,
|
||||
'total': str(invoice.total), # Alias for compatibility
|
||||
'total_amount': str(invoice.total),
|
||||
'subtotal': str(invoice.subtotal),
|
||||
'tax_amount': str(invoice.tax),
|
||||
@@ -565,6 +702,17 @@ class PaymentViewSet(AccountModelViewSet):
|
||||
queryset = Payment.objects.all().select_related('account', 'invoice')
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'payment_confirmation'
|
||||
|
||||
def get_throttles(self):
|
||||
"""Apply stricter throttling to manual payment submission"""
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
if self.action == 'manual':
|
||||
# 5 payment submissions per hour per user
|
||||
class PaymentSubmissionThrottle(UserRateThrottle):
|
||||
rate = '5/hour'
|
||||
return [PaymentSubmissionThrottle()]
|
||||
return super().get_throttles()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter payments by account"""
|
||||
@@ -605,6 +753,7 @@ class PaymentViewSet(AccountModelViewSet):
|
||||
'processed_at': payment.processed_at.isoformat() if payment.processed_at else None,
|
||||
'manual_reference': payment.manual_reference,
|
||||
'manual_notes': payment.manual_notes,
|
||||
# admin_notes intentionally excluded - internal only
|
||||
})
|
||||
|
||||
return paginated_response(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Invoice PDF views
|
||||
API endpoints for generating and downloading invoice PDFs
|
||||
"""
|
||||
from django.http import HttpResponse
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from igny8_core.business.billing.models import Invoice
|
||||
from igny8_core.business.billing.services.pdf_service import InvoicePDFGenerator
|
||||
from igny8_core.business.billing.utils.errors import not_found_response
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def download_invoice_pdf(request, invoice_id):
|
||||
"""
|
||||
Download invoice as PDF
|
||||
|
||||
GET /api/v1/billing/invoices/<id>/pdf/
|
||||
"""
|
||||
try:
|
||||
invoice = Invoice.objects.prefetch_related('line_items').get(
|
||||
id=invoice_id,
|
||||
account=request.user.account
|
||||
)
|
||||
except Invoice.DoesNotExist:
|
||||
return not_found_response('Invoice', invoice_id)
|
||||
|
||||
# Generate PDF
|
||||
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
|
||||
|
||||
# Return PDF response
|
||||
response = HttpResponse(pdf_buffer.read(), content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="invoice_{invoice.invoice_number}.pdf"'
|
||||
|
||||
logger.info(f'Invoice PDF downloaded: {invoice.invoice_number} by user {request.user.id}')
|
||||
|
||||
return response
|
||||
208
backend/igny8_core/business/billing/views/refund_views.py
Normal file
208
backend/igny8_core/business/billing/views/refund_views.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Refund workflow for payments
|
||||
Handles full and partial refunds with proper accounting
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from igny8_core.business.billing.models import Payment, CreditTransaction, Invoice
|
||||
from igny8_core.business.billing.utils.errors import (
|
||||
error_response, success_response, not_found_response, ErrorCode
|
||||
)
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def initiate_refund(request, payment_id):
|
||||
"""
|
||||
Initiate a refund for a payment
|
||||
|
||||
Request body:
|
||||
{
|
||||
"amount": "50.00", # Optional, defaults to full refund
|
||||
"reason": "Customer requested refund",
|
||||
"refund_credits": true # Whether to deduct credits
|
||||
}
|
||||
"""
|
||||
try:
|
||||
payment = Payment.objects.select_related('invoice', 'account').get(
|
||||
id=payment_id,
|
||||
account=request.user.account
|
||||
)
|
||||
except Payment.DoesNotExist:
|
||||
return not_found_response('Payment', payment_id)
|
||||
|
||||
# Validate payment can be refunded
|
||||
if payment.status != 'succeeded':
|
||||
return error_response(
|
||||
message='Only successful payments can be refunded',
|
||||
code=ErrorCode.INVALID_STATUS_TRANSITION,
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if payment.refunded_at:
|
||||
return error_response(
|
||||
message='This payment has already been refunded',
|
||||
code=ErrorCode.PAYMENT_ALREADY_PROCESSED,
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Parse refund amount
|
||||
refund_amount = request.data.get('amount')
|
||||
if refund_amount:
|
||||
refund_amount = Decimal(str(refund_amount))
|
||||
if refund_amount > Decimal(payment.amount):
|
||||
return error_response(
|
||||
message=f'Refund amount cannot exceed payment amount ({payment.amount})',
|
||||
code=ErrorCode.VALIDATION_ERROR,
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
else:
|
||||
refund_amount = Decimal(payment.amount)
|
||||
|
||||
reason = request.data.get('reason', 'Refund requested')
|
||||
refund_credits = request.data.get('refund_credits', True)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Process refund based on payment method
|
||||
refund_successful = False
|
||||
|
||||
if payment.payment_method == 'stripe':
|
||||
refund_successful = _process_stripe_refund(payment, refund_amount, reason)
|
||||
elif payment.payment_method == 'paypal':
|
||||
refund_successful = _process_paypal_refund(payment, refund_amount, reason)
|
||||
else:
|
||||
# Manual payment refund - mark as refunded
|
||||
refund_successful = True
|
||||
|
||||
if not refund_successful:
|
||||
return error_response(
|
||||
message='Refund processing failed. Please contact support.',
|
||||
code=ErrorCode.INTERNAL_ERROR,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
# Update payment record
|
||||
payment.status = 'refunded'
|
||||
payment.refunded_at = timezone.now()
|
||||
payment.metadata['refund_amount'] = str(refund_amount)
|
||||
payment.metadata['refund_reason'] = reason
|
||||
payment.save(update_fields=['status', 'refunded_at', 'metadata'])
|
||||
|
||||
# Update invoice if full refund
|
||||
if refund_amount == Decimal(payment.amount):
|
||||
invoice = payment.invoice
|
||||
if invoice.status == 'paid':
|
||||
invoice.status = 'pending'
|
||||
invoice.paid_at = None
|
||||
invoice.save(update_fields=['status', 'paid_at'])
|
||||
|
||||
# Deduct credits if applicable
|
||||
if refund_credits and payment.credit_transactions.exists():
|
||||
for credit_tx in payment.credit_transactions.all():
|
||||
if credit_tx.amount > 0: # Only deduct positive credits
|
||||
# Get current balance
|
||||
account = payment.account
|
||||
current_balance = account.credit_balance
|
||||
|
||||
# Create deduction transaction
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type='refund',
|
||||
amount=-credit_tx.amount,
|
||||
balance_after=current_balance - credit_tx.amount,
|
||||
description=f'Refund: {reason}',
|
||||
payment=payment,
|
||||
metadata={'original_transaction': credit_tx.id}
|
||||
)
|
||||
|
||||
# Update account balance
|
||||
account.credit_balance -= credit_tx.amount
|
||||
account.save(update_fields=['credit_balance'])
|
||||
|
||||
# Send refund notification email
|
||||
try:
|
||||
BillingEmailService.send_refund_notification(
|
||||
user=payment.account.owner,
|
||||
payment=payment,
|
||||
refund_amount=str(refund_amount),
|
||||
reason=reason
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send refund email for payment {payment_id}: {str(e)}")
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'payment_id': payment.id,
|
||||
'refund_amount': str(refund_amount),
|
||||
'status': 'refunded'
|
||||
},
|
||||
message='Refund processed successfully'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Refund error for payment {payment_id}: {str(e)}")
|
||||
return error_response(
|
||||
message='An error occurred while processing the refund',
|
||||
code=ErrorCode.INTERNAL_ERROR,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
stripe_client = get_stripe_client()
|
||||
|
||||
refund = stripe_client.Refund.create(
|
||||
payment_intent=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'
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stripe refund failed for payment {payment.id}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
|
||||
paypal_client = get_paypal_client()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
payment.metadata['paypal_refund_id'] = refund.id
|
||||
return refund.status == 'COMPLETED'
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"PayPal refund failed for payment {payment.id}: {str(e)}")
|
||||
return False
|
||||
@@ -125,29 +125,136 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
Override save_model to set approved_by when status changes to succeeded.
|
||||
The Payment.save() method will handle all the cascade updates automatically.
|
||||
Override save_model to trigger approval workflow when status changes to succeeded.
|
||||
This ensures manual status changes in admin also activate accounts and add credits.
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.auth.models import Subscription
|
||||
|
||||
# Check if status changed to 'succeeded'
|
||||
status_changed_to_succeeded = False
|
||||
if change and 'status' in form.changed_data:
|
||||
if obj.status == 'succeeded' and form.initial.get('status') != 'succeeded':
|
||||
status_changed_to_succeeded = True
|
||||
elif not change and obj.status == 'succeeded':
|
||||
status_changed_to_succeeded = True
|
||||
|
||||
# Save the payment first
|
||||
if obj.status == 'succeeded' and not obj.approved_by:
|
||||
obj.approved_by = request.user
|
||||
if not obj.approved_at:
|
||||
obj.approved_at = timezone.now()
|
||||
if not obj.processed_at:
|
||||
obj.processed_at = timezone.now()
|
||||
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
# If status changed to succeeded, trigger the full approval workflow
|
||||
if status_changed_to_succeeded:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
invoice = obj.invoice
|
||||
account = obj.account
|
||||
|
||||
# Get subscription from invoice or account
|
||||
subscription = None
|
||||
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
|
||||
subscription = invoice.subscription
|
||||
elif account and hasattr(account, 'subscription'):
|
||||
try:
|
||||
subscription = account.subscription
|
||||
except Subscription.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Update Invoice
|
||||
if invoice and invoice.status != 'paid':
|
||||
invoice.status = 'paid'
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save()
|
||||
|
||||
# Update Subscription
|
||||
if subscription and subscription.status != 'active':
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = obj.manual_reference
|
||||
subscription.save()
|
||||
|
||||
# Update Account
|
||||
if account.status != 'active':
|
||||
account.status = 'active'
|
||||
account.save()
|
||||
|
||||
# Add Credits (check if not already added)
|
||||
from igny8_core.business.billing.models import CreditTransaction
|
||||
existing_credit = CreditTransaction.objects.filter(
|
||||
account=account,
|
||||
metadata__payment_id=obj.id
|
||||
).exists()
|
||||
|
||||
if not existing_credit:
|
||||
credits_to_add = 0
|
||||
plan_name = ''
|
||||
|
||||
if subscription and subscription.plan:
|
||||
credits_to_add = subscription.plan.included_credits
|
||||
plan_name = subscription.plan.name
|
||||
elif account and account.plan:
|
||||
credits_to_add = account.plan.included_credits
|
||||
plan_name = account.plan.name
|
||||
|
||||
if credits_to_add > 0:
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_to_add,
|
||||
transaction_type='subscription',
|
||||
description=f'{plan_name} - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id if subscription else None,
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': obj.id,
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f'✓ Payment approved: Account activated, {credits_to_add} credits added',
|
||||
level='SUCCESS'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f'✗ Payment saved but workflow failed: {str(e)}',
|
||||
level='ERROR'
|
||||
)
|
||||
|
||||
def approve_payments(self, request, queryset):
|
||||
"""Approve selected manual payments"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.auth.models import Subscription
|
||||
|
||||
count = 0
|
||||
successful = []
|
||||
errors = []
|
||||
|
||||
for payment in queryset.filter(status='pending_approval'):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
invoice = payment.invoice
|
||||
subscription = invoice.subscription if hasattr(invoice, 'subscription') else None
|
||||
account = payment.account
|
||||
|
||||
# Get subscription from invoice or account
|
||||
subscription = None
|
||||
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
|
||||
subscription = invoice.subscription
|
||||
elif account and hasattr(account, 'subscription'):
|
||||
try:
|
||||
subscription = account.subscription
|
||||
except Subscription.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Update Payment
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = request.user
|
||||
@@ -172,10 +279,12 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
account.save()
|
||||
|
||||
# Add Credits
|
||||
if subscription and subscription.plan:
|
||||
credits_added = 0
|
||||
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
||||
credits_added = subscription.plan.included_credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=subscription.plan.included_credits,
|
||||
amount=credits_added,
|
||||
transaction_type='subscription',
|
||||
description=f'{subscription.plan.name} - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
@@ -185,17 +294,38 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
elif account and account.plan and account.plan.included_credits > 0:
|
||||
credits_added = account.plan.included_credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_added,
|
||||
transaction_type='subscription',
|
||||
description=f'{account.plan.name} - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': payment.id,
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
|
||||
count += 1
|
||||
successful.append(f'Payment #{payment.id} - {account.name} - Invoice {invoice.invoice_number} - {credits_added} credits')
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f'Payment {payment.id}: {str(e)}')
|
||||
errors.append(f'Payment #{payment.id}: {str(e)}')
|
||||
|
||||
if count:
|
||||
self.message_user(request, f'Successfully approved {count} payment(s)')
|
||||
# Detailed success message
|
||||
if successful:
|
||||
self.message_user(request, f'✓ Successfully approved {len(successful)} payment(s):', level='SUCCESS')
|
||||
for msg in successful[:10]: # Show first 10
|
||||
self.message_user(request, f' • {msg}', level='SUCCESS')
|
||||
if len(successful) > 10:
|
||||
self.message_user(request, f' ... and {len(successful) - 10} more', level='SUCCESS')
|
||||
|
||||
# Detailed error messages
|
||||
if errors:
|
||||
self.message_user(request, f'✗ Failed to approve {len(errors)} payment(s):', level='ERROR')
|
||||
for error in errors:
|
||||
self.message_user(request, error, level='ERROR')
|
||||
self.message_user(request, f' • {error}', level='ERROR')
|
||||
|
||||
approve_payments.short_description = 'Approve selected manual payments'
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Generated migration to add subscription FK to Invoice and fix Payment status default
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def populate_subscription_from_metadata(apps, schema_editor):
|
||||
"""Populate subscription FK from metadata for existing invoices"""
|
||||
# Use raw SQL to avoid model field issues during migration
|
||||
from django.db import connection
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Get invoices with subscription_id in metadata
|
||||
cursor.execute("""
|
||||
SELECT id, metadata
|
||||
FROM igny8_invoices
|
||||
WHERE metadata::text LIKE '%subscription_id%'
|
||||
""")
|
||||
|
||||
updated_count = 0
|
||||
for invoice_id, metadata in cursor.fetchall():
|
||||
if metadata and isinstance(metadata, dict) and 'subscription_id' in metadata:
|
||||
try:
|
||||
sub_id = int(metadata['subscription_id'])
|
||||
# Check if subscription exists
|
||||
cursor.execute(
|
||||
"SELECT id FROM igny8_subscriptions WHERE id = %s",
|
||||
[sub_id]
|
||||
)
|
||||
if cursor.fetchone():
|
||||
# Update invoice with subscription FK
|
||||
cursor.execute(
|
||||
"UPDATE igny8_invoices SET subscription_id = %s WHERE id = %s",
|
||||
[sub_id, invoice_id]
|
||||
)
|
||||
updated_count += 1
|
||||
except (ValueError, KeyError) as e:
|
||||
print(f"Could not populate subscription for invoice {invoice_id}: {e}")
|
||||
|
||||
print(f"Populated subscription FK for {updated_count} invoices")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0007_simplify_payment_statuses'),
|
||||
('igny8_core_auth', '0011_remove_subscription_payment_method'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add subscription FK to Invoice
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='subscription',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Subscription this invoice is for (if subscription-based)',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='invoices',
|
||||
to='igny8_core_auth.subscription'
|
||||
),
|
||||
),
|
||||
# Populate data
|
||||
migrations.RunPython(
|
||||
populate_subscription_from_metadata,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
# Fix Payment status default
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('pending_approval', 'Pending Approval'),
|
||||
('succeeded', 'Succeeded'),
|
||||
('failed', 'Failed'),
|
||||
('refunded', 'Refunded')
|
||||
],
|
||||
db_index=True,
|
||||
default='pending_approval',
|
||||
max_length=20
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
# Migration to add missing payment method configurations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def add_missing_payment_methods(apps, schema_editor):
|
||||
"""Add stripe and paypal global configs (disabled) and ensure all configs exist"""
|
||||
PaymentMethodConfig = apps.get_model('billing', 'PaymentMethodConfig')
|
||||
|
||||
# Add global Stripe (disabled - waiting for integration)
|
||||
PaymentMethodConfig.objects.get_or_create(
|
||||
country_code='*',
|
||||
payment_method='stripe',
|
||||
defaults={
|
||||
'is_enabled': False,
|
||||
'display_name': 'Credit/Debit Card (Stripe)',
|
||||
'instructions': 'Stripe payment integration coming soon.',
|
||||
'sort_order': 1
|
||||
}
|
||||
)
|
||||
|
||||
# Add global PayPal (disabled - waiting for integration)
|
||||
PaymentMethodConfig.objects.get_or_create(
|
||||
country_code='*',
|
||||
payment_method='paypal',
|
||||
defaults={
|
||||
'is_enabled': False,
|
||||
'display_name': 'PayPal',
|
||||
'instructions': 'PayPal payment integration coming soon.',
|
||||
'sort_order': 2
|
||||
}
|
||||
)
|
||||
|
||||
# Ensure global bank_transfer exists with good instructions
|
||||
PaymentMethodConfig.objects.get_or_create(
|
||||
country_code='*',
|
||||
payment_method='bank_transfer',
|
||||
defaults={
|
||||
'is_enabled': True,
|
||||
'display_name': 'Bank Transfer',
|
||||
'instructions': 'Bank transfer details will be provided after registration.',
|
||||
'sort_order': 3
|
||||
}
|
||||
)
|
||||
|
||||
# Add manual payment as global option
|
||||
PaymentMethodConfig.objects.get_or_create(
|
||||
country_code='*',
|
||||
payment_method='manual',
|
||||
defaults={
|
||||
'is_enabled': True,
|
||||
'display_name': 'Manual Payment (Contact Support)',
|
||||
'instructions': 'Contact support@igny8.com for manual payment arrangements.',
|
||||
'sort_order': 10
|
||||
}
|
||||
)
|
||||
|
||||
print("Added/updated payment method configurations")
|
||||
|
||||
|
||||
def remove_added_payment_methods(apps, schema_editor):
|
||||
"""Reverse migration"""
|
||||
PaymentMethodConfig = apps.get_model('billing', 'PaymentMethodConfig')
|
||||
PaymentMethodConfig.objects.filter(
|
||||
country_code='*',
|
||||
payment_method__in=['stripe', 'paypal', 'manual']
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0008_add_invoice_subscription_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
add_missing_payment_methods,
|
||||
reverse_code=remove_added_payment_methods
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Migration to add database constraints and indexes for data integrity
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0009_add_missing_payment_methods'),
|
||||
('igny8_core_auth', '0011_remove_subscription_payment_method'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add DB index on invoice_number for fast lookups
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
name='invoice_number',
|
||||
field=models.CharField(db_index=True, max_length=50, unique=True),
|
||||
),
|
||||
|
||||
# Add index on Payment.status for filtering
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('pending_approval', 'Pending Approval'),
|
||||
('succeeded', 'Succeeded'),
|
||||
('failed', 'Failed'),
|
||||
('refunded', 'Refunded')
|
||||
],
|
||||
db_index=True,
|
||||
default='pending_approval',
|
||||
max_length=20
|
||||
),
|
||||
),
|
||||
|
||||
# Add partial unique index on AccountPaymentMethod for single default per account
|
||||
# This prevents multiple is_default=True per account
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
CREATE UNIQUE INDEX billing_account_payment_method_single_default
|
||||
ON igny8_account_payment_methods (tenant_id)
|
||||
WHERE is_default = true AND is_enabled = true;
|
||||
""",
|
||||
reverse_sql="""
|
||||
DROP INDEX IF EXISTS billing_account_payment_method_single_default;
|
||||
"""
|
||||
),
|
||||
|
||||
# Add composite index on Payment for common queries
|
||||
migrations.AddIndex(
|
||||
model_name='payment',
|
||||
index=models.Index(
|
||||
fields=['account', 'status', '-created_at'],
|
||||
name='payment_account_status_created_idx'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated migration to add payment constraints
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0010_add_database_constraints'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add composite unique constraint on manual_reference + tenant_id
|
||||
# This prevents duplicate payment submissions with same reference for an account
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
CREATE UNIQUE INDEX billing_payment_manual_ref_account_unique
|
||||
ON igny8_payments(tenant_id, manual_reference)
|
||||
WHERE manual_reference != '' AND manual_reference IS NOT NULL;
|
||||
""",
|
||||
reverse_sql="""
|
||||
DROP INDEX IF EXISTS billing_payment_manual_ref_account_unique;
|
||||
"""
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated migration to add Payment FK to CreditTransaction
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0011_add_manual_reference_constraint'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add payment FK field
|
||||
migrations.AddField(
|
||||
model_name='credittransaction',
|
||||
name='payment',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='credit_transactions',
|
||||
to='billing.payment',
|
||||
help_text='Payment that triggered this credit transaction'
|
||||
),
|
||||
),
|
||||
|
||||
# Migrate existing reference_id data to payment FK
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
UPDATE igny8_credit_transactions ct
|
||||
SET payment_id = (
|
||||
SELECT id FROM igny8_payments p
|
||||
WHERE p.id::text = ct.reference_id
|
||||
AND ct.reference_id ~ '^[0-9]+$'
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE ct.reference_id IS NOT NULL
|
||||
AND ct.reference_id != ''
|
||||
AND ct.reference_id ~ '^[0-9]+$';
|
||||
""",
|
||||
reverse_sql=migrations.RunSQL.noop
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated migration to add webhook fields to PaymentMethodConfig
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0012_add_payment_fk_to_credit_transaction'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add webhook configuration fields
|
||||
migrations.AddField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='webhook_url',
|
||||
field=models.URLField(
|
||||
blank=True,
|
||||
help_text='Webhook URL for payment gateway callbacks (Stripe/PayPal)'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='webhook_secret',
|
||||
field=models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text='Webhook secret for signature verification'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='api_key',
|
||||
field=models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text='API key for payment gateway integration'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='api_secret',
|
||||
field=models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text='API secret for payment gateway integration'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Serializers for Billing Models
|
||||
"""
|
||||
from typing import Any, Dict, Optional
|
||||
from decimal import Decimal
|
||||
from rest_framework import serializers
|
||||
from .models import CreditTransaction, CreditUsageLog
|
||||
from igny8_core.auth.models import Account
|
||||
@@ -8,7 +10,11 @@ from igny8_core.business.billing.models import PaymentMethodConfig, Payment
|
||||
|
||||
|
||||
class CreditTransactionSerializer(serializers.ModelSerializer):
|
||||
transaction_type_display = serializers.CharField(source='get_transaction_type_display', read_only=True)
|
||||
"""Serializer for credit transactions"""
|
||||
transaction_type_display: serializers.CharField = serializers.CharField(
|
||||
source='get_transaction_type_display',
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CreditTransaction
|
||||
@@ -20,7 +26,11 @@ class CreditTransactionSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class CreditUsageLogSerializer(serializers.ModelSerializer):
|
||||
operation_type_display = serializers.CharField(source='get_operation_type_display', read_only=True)
|
||||
"""Serializer for credit usage logs"""
|
||||
operation_type_display: serializers.CharField = serializers.CharField(
|
||||
source='get_operation_type_display',
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CreditUsageLog
|
||||
@@ -34,24 +44,27 @@ class CreditUsageLogSerializer(serializers.ModelSerializer):
|
||||
|
||||
class CreditBalanceSerializer(serializers.Serializer):
|
||||
"""Serializer for credit balance response"""
|
||||
credits = serializers.IntegerField()
|
||||
plan_credits_per_month = serializers.IntegerField()
|
||||
credits_used_this_month = serializers.IntegerField()
|
||||
credits_remaining = serializers.IntegerField()
|
||||
credits: serializers.IntegerField = serializers.IntegerField()
|
||||
plan_credits_per_month: serializers.IntegerField = serializers.IntegerField()
|
||||
credits_used_this_month: serializers.IntegerField = serializers.IntegerField()
|
||||
credits_remaining: serializers.IntegerField = serializers.IntegerField()
|
||||
|
||||
|
||||
class UsageSummarySerializer(serializers.Serializer):
|
||||
"""Serializer for usage summary response"""
|
||||
period = serializers.DictField()
|
||||
total_credits_used = serializers.IntegerField()
|
||||
total_cost_usd = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||
by_operation = serializers.DictField()
|
||||
by_model = serializers.DictField()
|
||||
period: serializers.DictField = serializers.DictField()
|
||||
total_credits_used: serializers.IntegerField = serializers.IntegerField()
|
||||
total_cost_usd: serializers.DecimalField = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||
by_operation: serializers.DictField = serializers.DictField()
|
||||
by_model: serializers.DictField = serializers.DictField()
|
||||
|
||||
|
||||
class PaymentMethodConfigSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for payment method configuration"""
|
||||
payment_method_display = serializers.CharField(source='get_payment_method_display', read_only=True)
|
||||
payment_method_display: serializers.CharField = serializers.CharField(
|
||||
source='get_payment_method_display',
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PaymentMethodConfig
|
||||
@@ -66,43 +79,66 @@ class PaymentMethodConfigSerializer(serializers.ModelSerializer):
|
||||
|
||||
class PaymentConfirmationSerializer(serializers.Serializer):
|
||||
"""Serializer for manual payment confirmation"""
|
||||
invoice_id = serializers.IntegerField(required=True)
|
||||
payment_method = serializers.ChoiceField(
|
||||
invoice_id: serializers.IntegerField = serializers.IntegerField(required=True)
|
||||
payment_method: serializers.ChoiceField = serializers.ChoiceField(
|
||||
choices=['bank_transfer', 'local_wallet'],
|
||||
required=True
|
||||
)
|
||||
manual_reference = serializers.CharField(
|
||||
manual_reference: serializers.CharField = serializers.CharField(
|
||||
required=True,
|
||||
max_length=255,
|
||||
help_text="Transaction reference number"
|
||||
)
|
||||
manual_notes = serializers.CharField(
|
||||
manual_notes: serializers.CharField = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Additional notes about the payment"
|
||||
)
|
||||
amount = serializers.DecimalField(
|
||||
amount: serializers.DecimalField = serializers.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
required=True
|
||||
)
|
||||
proof_url = serializers.URLField(
|
||||
proof_url: serializers.URLField = serializers.URLField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="URL to receipt/proof of payment"
|
||||
)
|
||||
|
||||
def validate_proof_url(self, value: Optional[str]) -> Optional[str]:
|
||||
"""Validate proof_url is a valid URL format"""
|
||||
if value and not value.strip():
|
||||
raise serializers.ValidationError("Proof URL cannot be empty if provided")
|
||||
if value:
|
||||
# Additional validation: must be http or https
|
||||
if not value.startswith(('http://', 'https://')):
|
||||
raise serializers.ValidationError("Proof URL must start with http:// or https://")
|
||||
return value
|
||||
|
||||
def validate_amount(self, value: Optional[Decimal]) -> Decimal:
|
||||
"""Validate amount has max 2 decimal places"""
|
||||
if value is None:
|
||||
raise serializers.ValidationError("Amount is required")
|
||||
if value <= 0:
|
||||
raise serializers.ValidationError("Amount must be greater than 0")
|
||||
# Check decimal precision (max 2 decimal places)
|
||||
if value.as_tuple().exponent < -2:
|
||||
raise serializers.ValidationError("Amount can have maximum 2 decimal places")
|
||||
return value
|
||||
|
||||
|
||||
class LimitCardSerializer(serializers.Serializer):
|
||||
"""Serializer for individual limit card"""
|
||||
title = serializers.CharField()
|
||||
limit = serializers.IntegerField()
|
||||
used = serializers.IntegerField()
|
||||
available = serializers.IntegerField()
|
||||
unit = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
percentage = serializers.FloatField()
|
||||
title: serializers.CharField = serializers.CharField()
|
||||
limit: serializers.IntegerField = serializers.IntegerField()
|
||||
used: serializers.IntegerField = serializers.IntegerField()
|
||||
available: serializers.IntegerField = serializers.IntegerField()
|
||||
unit: serializers.CharField = serializers.CharField()
|
||||
category: serializers.CharField = serializers.CharField()
|
||||
percentage: serializers.FloatField = serializers.FloatField()
|
||||
|
||||
|
||||
class UsageLimitsSerializer(serializers.Serializer):
|
||||
"""Serializer for usage limits response"""
|
||||
limits = LimitCardSerializer(many=True)
|
||||
limits: LimitCardSerializer = LimitCardSerializer(many=True)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { addError } = useErrorHandler('ProtectedRoute');
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
|
||||
const PLAN_ALLOWED_PATHS = [
|
||||
'/account/plans',
|
||||
'/account/purchase-credits',
|
||||
@@ -32,6 +34,15 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
location.pathname.startsWith(prefix)
|
||||
);
|
||||
|
||||
// Give the auth store a moment to initialize on mount
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsInitializing(false);
|
||||
}, 100); // Short delay to let Zustand hydrate
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Track loading state
|
||||
useEffect(() => {
|
||||
trackLoading('auth-loading', loading);
|
||||
@@ -82,13 +93,15 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
}
|
||||
}, [loading, addError]);
|
||||
|
||||
// Show loading state while checking authentication
|
||||
if (loading) {
|
||||
// Show loading state while checking authentication or initializing
|
||||
if (loading || isInitializing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mb-4"></div>
|
||||
<p className="text-lg font-medium text-gray-800 dark:text-white mb-2">Loading...</p>
|
||||
<p className="text-lg font-medium text-gray-800 dark:text-white mb-2">
|
||||
{isInitializing ? 'Initializing...' : 'Loading...'}
|
||||
</p>
|
||||
|
||||
{showError && (
|
||||
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
@@ -112,8 +125,9 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to signin if not authenticated
|
||||
// Redirect to signin if not authenticated (after initialization period)
|
||||
if (!isAuthenticated) {
|
||||
console.log('ProtectedRoute: Not authenticated, redirecting to signin');
|
||||
return <Navigate to="/signin" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
|
||||
email: '',
|
||||
password: '',
|
||||
accountName: '',
|
||||
billingCountry: 'US', // Default to US for payment method filtering
|
||||
});
|
||||
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
|
||||
@@ -91,7 +92,8 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
|
||||
setPaymentMethodsLoading(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/admin/payment-methods/`);
|
||||
const country = formData.billingCountry || 'US';
|
||||
const response = await fetch(`${API_BASE_URL}/v1/billing/admin/payment-methods/?country=${country}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load payment methods');
|
||||
@@ -125,7 +127,7 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
|
||||
};
|
||||
|
||||
loadPaymentMethods();
|
||||
}, [isPaidPlan]);
|
||||
}, [isPaidPlan, formData.billingCountry]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
@@ -171,6 +173,7 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
|
||||
registerPayload.payment_method = selectedPaymentMethod;
|
||||
// Use email as billing email by default
|
||||
registerPayload.billing_email = formData.email;
|
||||
registerPayload.billing_country = formData.billingCountry;
|
||||
}
|
||||
|
||||
const user = await register(registerPayload) as any;
|
||||
@@ -314,6 +317,31 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
|
||||
{/* Payment Method Selection for Paid Plans */}
|
||||
{isPaidPlan && (
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{/* Country Selection */}
|
||||
<div className="mb-5">
|
||||
<Label>
|
||||
Country<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<select
|
||||
name="billingCountry"
|
||||
value={formData.billingCountry}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
>
|
||||
<option value="US">United States</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="IN">India</option>
|
||||
<option value="PK">Pakistan</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="AU">Australia</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="FR">France</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Payment methods will be filtered by your country
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<Label>
|
||||
Payment Method<span className="text-error-500">*</span>
|
||||
|
||||
645
frontend/src/components/auth/SignUpFormUnified.tsx
Normal file
645
frontend/src/components/auth/SignUpFormUnified.tsx
Normal file
@@ -0,0 +1,645 @@
|
||||
/**
|
||||
* Unified Signup Form with Integrated Pricing Selection
|
||||
* Combines free and paid signup flows in one modern interface
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from '../../icons';
|
||||
import { CreditCard, Building2, Wallet, Check, Loader2, CheckCircle } from 'lucide-react';
|
||||
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';
|
||||
|
||||
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;
|
||||
monthly_word_count_limit: number;
|
||||
included_credits: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
interface PaymentMethodConfig {
|
||||
id: number;
|
||||
payment_method: string;
|
||||
display_name: string;
|
||||
instructions: string | null;
|
||||
country_code: string;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
interface SignUpFormUnifiedProps {
|
||||
plans: Plan[];
|
||||
selectedPlan: Plan | null;
|
||||
onPlanSelect: (plan: Plan) => void;
|
||||
plansLoading: boolean;
|
||||
}
|
||||
|
||||
export default function SignUpFormUnified({
|
||||
plans,
|
||||
selectedPlan,
|
||||
onPlanSelect,
|
||||
plansLoading,
|
||||
}: SignUpFormUnifiedProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annually'>('monthly');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
accountName: '',
|
||||
billingCountry: 'US',
|
||||
});
|
||||
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethodConfig[]>([]);
|
||||
const [paymentMethodsLoading, setPaymentMethodsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { register, loading } = useAuthStore();
|
||||
|
||||
const isPaidPlan = selectedPlan && parseFloat(String(selectedPlan.price || 0)) > 0;
|
||||
|
||||
// Update URL when plan changes
|
||||
useEffect(() => {
|
||||
if (selectedPlan) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('plan', selectedPlan.slug);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
}, [selectedPlan]);
|
||||
|
||||
// Load payment methods for paid plans
|
||||
useEffect(() => {
|
||||
if (!isPaidPlan) {
|
||||
setPaymentMethods([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPaymentMethods = async () => {
|
||||
setPaymentMethodsLoading(true);
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
||||
const country = formData.billingCountry || 'US';
|
||||
const response = await fetch(`${API_BASE_URL}/v1/billing/payment-configs/payment-methods/?country=${country}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load payment methods');
|
||||
}
|
||||
|
||||
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);
|
||||
setPaymentMethods(enabledMethods);
|
||||
|
||||
if (enabledMethods.length > 0 && !selectedPaymentMethod) {
|
||||
setSelectedPaymentMethod(enabledMethods[0].payment_method);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load payment methods:', err);
|
||||
// Don't set error for free plans or if payment methods fail to load
|
||||
// Just log it and continue
|
||||
} finally {
|
||||
setPaymentMethodsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPaymentMethods();
|
||||
}, [isPaidPlan, formData.billingCountry]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isChecked) {
|
||||
setError('Please agree to the Terms and Conditions');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPlan) {
|
||||
setError('Please select a plan');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaidPlan && !selectedPaymentMethod) {
|
||||
setError('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const username = formData.email.split('@')[0];
|
||||
|
||||
const registerPayload: any = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
username: username,
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
account_name: formData.accountName,
|
||||
plan_slug: selectedPlan.slug,
|
||||
};
|
||||
|
||||
if (isPaidPlan) {
|
||||
registerPayload.payment_method = selectedPaymentMethod;
|
||||
registerPayload.billing_email = formData.email;
|
||||
registerPayload.billing_country = formData.billingCountry;
|
||||
}
|
||||
|
||||
const user = (await register(registerPayload)) as any;
|
||||
|
||||
// CRITICAL: Verify auth state is actually set in Zustand store
|
||||
// The register function should have already set isAuthenticated=true
|
||||
const currentAuthState = useAuthStore.getState();
|
||||
|
||||
console.log('Post-registration auth state check:', {
|
||||
isAuthenticated: currentAuthState.isAuthenticated,
|
||||
hasUser: !!currentAuthState.user,
|
||||
hasToken: !!currentAuthState.token,
|
||||
userData: user
|
||||
});
|
||||
|
||||
// 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...');
|
||||
|
||||
// Extract tokens from user data if available
|
||||
const tokenData = user?.tokens || {};
|
||||
const accessToken = user?.access || tokenData.access || localStorage.getItem('access_token');
|
||||
const refreshToken = user?.refresh || tokenData.refresh || localStorage.getItem('refresh_token');
|
||||
|
||||
// Force set the state
|
||||
useAuthStore.setState({
|
||||
user: user,
|
||||
token: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
isAuthenticated: true,
|
||||
loading: false
|
||||
});
|
||||
|
||||
// Wait a bit for state to propagate
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// Final verification before navigation
|
||||
const finalState = useAuthStore.getState();
|
||||
if (!finalState.isAuthenticated) {
|
||||
throw new Error('Failed to authenticate after registration. Please try logging in manually.');
|
||||
}
|
||||
|
||||
const status = user?.account?.status;
|
||||
if (status === 'pending_payment') {
|
||||
navigate('/account/plans', { replace: true });
|
||||
} else {
|
||||
navigate('/sites', { replace: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentIcon = (method: string) => {
|
||||
switch (method) {
|
||||
case 'stripe':
|
||||
return <CreditCard className="w-5 h-5" />;
|
||||
case 'bank_transfer':
|
||||
return <Building2 className="w-5 h-5" />;
|
||||
case 'local_wallet':
|
||||
return <Wallet className="w-5 h-5" />;
|
||||
default:
|
||||
return <CreditCard className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const extractFeatures = (plan: Plan): string[] => {
|
||||
const features: string[] = [];
|
||||
features.push(`${plan.max_sites} ${plan.max_sites === 1 ? 'Site' : 'Sites'}`);
|
||||
features.push(`${plan.max_users} ${plan.max_users === 1 ? 'User' : 'Users'}`);
|
||||
features.push(`${formatNumber(plan.max_keywords || 0)} Keywords`);
|
||||
features.push(`${formatNumber(plan.monthly_word_count_limit || 0)} Words/Month`);
|
||||
features.push(`${formatNumber(plan.included_credits || 0)} AI Credits`);
|
||||
return features;
|
||||
};
|
||||
|
||||
const getDisplayPrice = (plan: Plan): number => {
|
||||
const monthlyPrice = typeof plan.price === 'number' ? plan.price : parseFloat(String(plan.price || 0));
|
||||
if (billingPeriod === 'annually') {
|
||||
return monthlyPrice * 12 * 0.85; // 15% discount
|
||||
}
|
||||
return monthlyPrice;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:w-1/2 w-full">
|
||||
{/* Mobile Pricing Toggle */}
|
||||
<div className="lg:hidden bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative inline-flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg shadow-sm">
|
||||
<span
|
||||
className={`absolute top-1/2 left-1 flex h-9 w-28 -translate-y-1/2 rounded-md bg-gradient-to-br from-brand-500 to-brand-600 shadow-md transition-all duration-300 ease-out ${
|
||||
billingPeriod === 'monthly' ? 'translate-x-0' : 'translate-x-28'
|
||||
}`}
|
||||
></span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingPeriod('monthly')}
|
||||
className={`relative flex h-9 w-28 items-center justify-center text-sm font-semibold transition-all duration-200 rounded-md ${
|
||||
billingPeriod === 'monthly' ? 'text-white' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingPeriod('annually')}
|
||||
className={`relative flex h-9 w-28 items-center justify-center text-sm font-semibold transition-all duration-200 rounded-md ${
|
||||
billingPeriod === 'annually' ? 'text-white' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Annually
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-6 flex items-center justify-center">
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 font-semibold bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded-full transition-opacity duration-200 ${
|
||||
billingPeriod === 'annually' ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Save 15%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto no-scrollbar flex items-center">
|
||||
<div className="w-full max-w-md mx-auto p-6 sm:p-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 mb-6"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
Back to dashboard
|
||||
</Link>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2 font-semibold text-gray-800 dark:text-white text-2xl">Sign Up for {selectedPlan?.name || 'IGNY8'}</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Complete your registration and select a payment method.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plan Selection - Mobile */}
|
||||
<div className="lg:hidden mb-6">
|
||||
<Label>Select Plan</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{plans.map((plan) => {
|
||||
const displayPrice = getDisplayPrice(plan);
|
||||
const isSelected = selectedPlan?.id === plan.id;
|
||||
const isFree = parseFloat(String(plan.price || 0)) === 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={plan.id}
|
||||
onClick={() => onPlanSelect(plan)}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||||
isSelected
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`font-semibold text-sm ${isSelected ? 'text-brand-600 dark:text-brand-400' : 'text-gray-900 dark:text-white'}`}>
|
||||
{plan.name}
|
||||
</span>
|
||||
{isSelected && <CheckCircle className="w-4 h-4 text-brand-500" />}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{isFree ? 'Free' : `$${displayPrice.toFixed(2)}`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{billingPeriod === 'annually' && !isFree ? '/year' : '/month'}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>
|
||||
First Name<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input type="text" name="firstName" value={formData.firstName} onChange={handleChange} placeholder="Enter your first name" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Last Name<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input type="text" name="lastName" value={formData.lastName} onChange={handleChange} placeholder="Enter your last name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Email<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input type="email" name="email" value={formData.email} onChange={handleChange} placeholder="Enter your email" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Account Name (optional)</Label>
|
||||
<Input type="text" name="accountName" value={formData.accountName} onChange={handleChange} placeholder="Workspace / Company name" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Password<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input placeholder="Enter your password" type={showPassword ? 'text' : 'password'} name="password" value={formData.password} onChange={handleChange} />
|
||||
<span onClick={() => setShowPassword(!showPassword)} className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2">
|
||||
{showPassword ? <EyeIcon className="fill-gray-500 dark:fill-gray-400 size-5" /> : <EyeCloseIcon className="fill-gray-500 dark:fill-gray-400 size-5" />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPaidPlan && (
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-4">
|
||||
<div>
|
||||
<Label>
|
||||
Country<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'US', label: '🇺🇸 United States' },
|
||||
{ value: 'GB', label: '🇬🇧 United Kingdom' },
|
||||
{ value: 'IN', label: '🇮🇳 India' },
|
||||
{ value: 'PK', label: '🇵🇰 Pakistan' },
|
||||
{ value: 'CA', label: '🇨🇦 Canada' },
|
||||
{ value: 'AU', label: '🇦🇺 Australia' },
|
||||
{ value: 'DE', label: '🇩🇪 Germany' },
|
||||
{ value: 'FR', label: '🇫🇷 France' },
|
||||
]}
|
||||
value={formData.billingCountry}
|
||||
onChange={(value) => setFormData((prev) => ({ ...prev, billingCountry: value }))}
|
||||
className="text-base"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Payment methods will be filtered by your country</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Payment Method<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-2">Select how you'd like to pay for your subscription</p>
|
||||
|
||||
{paymentMethodsLoading ? (
|
||||
<div className="flex items-center justify-center p-6 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-brand-500 mr-2" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Loading payment options...</span>
|
||||
</div>
|
||||
) : paymentMethods.length === 0 ? (
|
||||
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-800 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-200">
|
||||
<p className="text-sm">No payment methods available. Please contact support.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{paymentMethods.map((method) => (
|
||||
<div
|
||||
key={method.id}
|
||||
onClick={() => setSelectedPaymentMethod(method.payment_method)}
|
||||
className={`relative p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
selectedPaymentMethod === method.payment_method
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-lg ${
|
||||
selectedPaymentMethod === method.payment_method ? 'bg-brand-500 text-white' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{getPaymentIcon(method.payment_method)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">{method.display_name}</h4>
|
||||
{selectedPaymentMethod === method.payment_method && <Check className="w-5 h-5 text-brand-500" />}
|
||||
</div>
|
||||
{method.instructions && <p className="text-xs text-gray-500 dark:text-gray-400 mt-1 whitespace-pre-line">{method.instructions}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3 pt-2">
|
||||
<Checkbox className="w-5 h-5 mt-0.5" checked={isChecked} onChange={setIsChecked} />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
By creating an account means you agree to the <span className="text-gray-800 dark:text-white/90">Terms and Conditions</span>, and our{' '}
|
||||
<span className="text-gray-800 dark:text-white">Privacy Policy</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" disabled={loading} className="w-full">
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Creating your account...
|
||||
</span>
|
||||
) : isPaidPlan ? (
|
||||
'Create Account & Continue to Payment'
|
||||
) : (
|
||||
'Start Free Trial'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 text-center">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-400">
|
||||
Already have an account?{' '}
|
||||
<Link to="/signin" className="text-brand-500 hover:text-brand-600 dark:text-brand-400 font-medium">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Pricing Panel - Renders in right side */}
|
||||
<div className="hidden lg:block">
|
||||
{/* This will be portaled to the right side */}
|
||||
{typeof document !== 'undefined' &&
|
||||
document.getElementById('signup-pricing-plans') &&
|
||||
ReactDOM.createPortal(
|
||||
<div className="space-y-8">
|
||||
{/* Billing Toggle - Improved UI */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="relative inline-flex p-1 bg-gray-100 dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<span
|
||||
className={`absolute top-1/2 left-1 flex h-11 w-32 -translate-y-1/2 rounded-lg bg-gradient-to-br from-brand-500 to-brand-600 shadow-md transition-all duration-300 ease-out ${
|
||||
billingPeriod === 'monthly' ? 'translate-x-0' : 'translate-x-32'
|
||||
}`}
|
||||
></span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingPeriod('monthly')}
|
||||
className={`relative flex h-11 w-32 items-center justify-center text-base font-semibold transition-all duration-200 rounded-lg ${
|
||||
billingPeriod === 'monthly' ? 'text-white' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingPeriod('annually')}
|
||||
className={`relative flex h-11 w-32 items-center justify-center text-base font-semibold transition-all duration-200 rounded-lg ${
|
||||
billingPeriod === 'annually' ? 'text-white' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Annually
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-7 flex items-center justify-center">
|
||||
<p className={`inline-flex items-center gap-1.5 text-green-600 dark:text-green-400 text-sm font-semibold bg-green-50 dark:bg-green-900/20 px-3 py-1.5 rounded-full transition-opacity duration-200 ${
|
||||
billingPeriod === 'annually' ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Save 15% with annual billing
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan Cards - 2 columns, wider cards, no buttons */}
|
||||
<div className="grid gap-5 grid-cols-1 xl:grid-cols-2">
|
||||
{plans.map((plan) => {
|
||||
const displayPrice = getDisplayPrice(plan);
|
||||
const features = extractFeatures(plan);
|
||||
const isSelected = selectedPlan?.id === plan.id;
|
||||
const isFree = parseFloat(String(plan.price || 0)) === 0;
|
||||
const isPopular = plan.slug.toLowerCase().includes('growth');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
onClick={() => onPlanSelect(plan)}
|
||||
className={`relative rounded-2xl p-6 cursor-pointer transition-all duration-300 border-2 ${
|
||||
isSelected
|
||||
? 'border-brand-500 bg-white dark:bg-gray-800 shadow-2xl ring-4 ring-brand-500/20'
|
||||
: isPopular
|
||||
? 'border-brand-200 dark:border-brand-800 bg-white dark:bg-gray-800/50 hover:shadow-xl'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/50 hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
{isPopular && !isSelected && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs font-bold px-3 py-1 rounded-full shadow-lg">
|
||||
⭐ POPULAR
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-4 -right-4">
|
||||
<div className="bg-brand-500 rounded-full p-2 shadow-lg">
|
||||
<CheckCircle className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-5">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">{plan.name}</h3>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||
{isFree ? 'Free' : `$${displayPrice.toFixed(2)}`}
|
||||
</span>
|
||||
<div className="h-5 flex items-center">
|
||||
{!isFree && (
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{billingPeriod === 'annually' ? '/year' : '/month'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-5">
|
||||
{billingPeriod === 'annually' && !isFree && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
${(displayPrice / 12).toFixed(2)}/month billed annually
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features - 3 rows x 2 columns = 6 features */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5">
|
||||
{features.slice(0, 6).map((feature, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 leading-tight">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.getElementById('signup-pricing-plans')!
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,8 @@ interface PaymentConfirmationModalProps {
|
||||
invoice: {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
total_amount: string; // Backend returns 'total_amount' in API response
|
||||
total?: string; // For backward compatibility
|
||||
total_amount?: string; // Backend returns 'total_amount'
|
||||
currency?: string;
|
||||
};
|
||||
paymentMethod: {
|
||||
@@ -110,6 +111,10 @@ export default function PaymentConfirmationModal({
|
||||
return;
|
||||
}
|
||||
|
||||
// Create AbortController for timeout handling
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@@ -121,16 +126,19 @@ export default function PaymentConfirmationModal({
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
invoice_id: invoice.id,
|
||||
payment_method: paymentMethod.payment_method,
|
||||
amount: invoice.total_amount,
|
||||
amount: invoice.total_amount || invoice.total || '0', // Handle both field names
|
||||
manual_reference: formData.manual_reference.trim(),
|
||||
manual_notes: formData.manual_notes.trim() || undefined,
|
||||
proof_url: formData.proof_url || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
@@ -139,7 +147,7 @@ export default function PaymentConfirmationModal({
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Show success message for 2 seconds, then close and call onSuccess
|
||||
// Show success message for 5 seconds (increased from 2s), then close and call onSuccess
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
@@ -148,9 +156,14 @@ export default function PaymentConfirmationModal({
|
||||
setUploadedFile(null);
|
||||
setUploadedFileName('');
|
||||
setSuccess(false);
|
||||
}, 2000);
|
||||
}, 5000);
|
||||
} catch (err: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
setError('Request timeout. Please check your connection and try again.');
|
||||
} else {
|
||||
setError(err.message || 'Failed to submit payment confirmation');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -197,7 +210,7 @@ export default function PaymentConfirmationModal({
|
||||
<div>
|
||||
<span className="text-gray-600 dark:text-gray-400">Amount:</span>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{invoice.currency || 'USD'} {invoice.total_amount}
|
||||
{invoice.currency?.toUpperCase() || 'USD'} {parseFloat(invoice.total_amount || invoice.total || '0').toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
|
||||
175
frontend/src/components/billing/PaymentHistory.tsx
Normal file
175
frontend/src/components/billing/PaymentHistory.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { API_BASE_URL } from '../../services/api';
|
||||
import Button from '../ui/button/Button';
|
||||
import { CheckCircle, XCircle, Clock, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Payment {
|
||||
id: number;
|
||||
invoice_id: number;
|
||||
invoice_number: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
payment_method: string;
|
||||
created_at: string;
|
||||
processed_at?: string;
|
||||
manual_reference?: string;
|
||||
manual_notes?: string;
|
||||
}
|
||||
|
||||
export default function PaymentHistory() {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const { token } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadPayments();
|
||||
}, []);
|
||||
|
||||
const loadPayments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/v1/billing/payments/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setPayments(data.results || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load payment history');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||
case 'pending_approval':
|
||||
return <Clock className="w-5 h-5 text-yellow-500" />;
|
||||
default:
|
||||
return <Clock className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges = {
|
||||
succeeded: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
|
||||
pending_approval: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
|
||||
refunded: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400',
|
||||
};
|
||||
return badges[status as keyof typeof badges] || badges.pending_approval;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-12">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Payment History
|
||||
</h2>
|
||||
<Button onClick={loadPayments} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-600 dark:bg-red-900/20 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<p>No payment history yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Invoice
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Method
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Reference
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||
#{payment.invoice_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{payment.currency} {payment.amount}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
{payment.payment_method.replace('_', ' ')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(payment.status)}
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusBadge(payment.status)}`}>
|
||||
{payment.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatDate(payment.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
{payment.manual_reference || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import PaymentConfirmationModal from './PaymentConfirmationModal';
|
||||
interface Invoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
total_amount: string; // Backend returns 'total_amount' in serialized response
|
||||
total?: string; // For backward compatibility
|
||||
total_amount?: string; // Backend returns 'total_amount'
|
||||
currency: string;
|
||||
status: string;
|
||||
due_date?: string;
|
||||
@@ -65,9 +66,9 @@ export default function PendingPaymentBanner({ className = '' }: PendingPaymentB
|
||||
if (response.ok && data.success && data.results?.length > 0) {
|
||||
setInvoice(data.results[0]);
|
||||
|
||||
// Load payment method if available
|
||||
// Load payment method if available - use public endpoint
|
||||
const country = (user?.account as any)?.billing_country || 'US';
|
||||
const pmResponse = await fetch(`${API_BASE_URL}/v1/billing/admin/payment-methods/?country=${country}`, {
|
||||
const pmResponse = await fetch(`${API_BASE_URL}/v1/billing/payment-configs/payment-methods/?country=${country}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -75,8 +76,10 @@ export default function PendingPaymentBanner({ className = '' }: PendingPaymentB
|
||||
});
|
||||
|
||||
const pmData = await pmResponse.json();
|
||||
// API returns array directly from DRF Response
|
||||
if (pmResponse.ok && Array.isArray(pmData) && pmData.length > 0) {
|
||||
// Use public endpoint response format
|
||||
if (pmResponse.ok && pmData.success && pmData.results?.length > 0) {
|
||||
setPaymentMethod(pmData.results[0]);
|
||||
} else if (pmResponse.ok && Array.isArray(pmData) && pmData.length > 0) {
|
||||
setPaymentMethod(pmData[0]);
|
||||
}
|
||||
}
|
||||
@@ -123,10 +126,15 @@ export default function PendingPaymentBanner({ className = '' }: PendingPaymentB
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">
|
||||
Your account is pending payment. Please complete your payment to activate your subscription.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Link to="/account/plans">
|
||||
<Button variant="primary" size="sm">
|
||||
View Billing Details
|
||||
Complete Payment
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/dashboard">
|
||||
<Button variant="outline" size="sm">
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -251,13 +259,17 @@ export default function PendingPaymentBanner({ className = '' }: PendingPaymentB
|
||||
</div>
|
||||
|
||||
{/* Payment Confirmation Modal */}
|
||||
{showPaymentModal && invoice && paymentMethod && (
|
||||
{showPaymentModal && invoice && (
|
||||
<PaymentConfirmationModal
|
||||
isOpen={showPaymentModal}
|
||||
onClose={() => setShowPaymentModal(false)}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
invoice={invoice}
|
||||
paymentMethod={paymentMethod}
|
||||
paymentMethod={paymentMethod || {
|
||||
payment_method: 'bank_transfer',
|
||||
display_name: 'Bank Transfer',
|
||||
country_code: 'US'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import AuthLayout from "./AuthPageLayout";
|
||||
import SignUpFormSimplified from "../../components/auth/SignUpFormSimplified";
|
||||
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_clusters: number;
|
||||
max_content_ideas: number;
|
||||
monthly_word_count_limit: number;
|
||||
monthly_ai_credit_limit: number;
|
||||
monthly_image_count: number;
|
||||
daily_content_tasks: number;
|
||||
daily_ai_request_limit: number;
|
||||
daily_image_generation_limit: number;
|
||||
included_credits: number;
|
||||
image_model_choices: string[];
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export default function SignUp() {
|
||||
const planSlug = useMemo(() => {
|
||||
@@ -9,29 +32,45 @@ export default function SignUp() {
|
||||
return params.get("plan") || "";
|
||||
}, []);
|
||||
|
||||
const [planDetails, setPlanDetails] = useState<any | null>(null);
|
||||
const [planLoading, setPlanLoading] = useState(false);
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [plansLoading, setPlansLoading] = useState(true);
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
if (!planSlug) return;
|
||||
setPlanLoading(true);
|
||||
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 plans = data?.results || [];
|
||||
const plan = plans.find((p: any) => p.slug === planSlug);
|
||||
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) {
|
||||
const features = Array.isArray(plan.features)
|
||||
? plan.features.map((f: string) => f.charAt(0).toUpperCase() + f.slice(1))
|
||||
: [];
|
||||
setPlanDetails({ ...plan, features });
|
||||
setSelectedPlan(plan);
|
||||
} else {
|
||||
setSelectedPlan(publicPlans[0] || null);
|
||||
}
|
||||
} else {
|
||||
setSelectedPlan(publicPlans[0] || null);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore; SignUpForm will handle lack of plan data gracefully
|
||||
console.error('Failed to load plans:', e);
|
||||
} finally {
|
||||
setPlanLoading(false);
|
||||
setPlansLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPlans();
|
||||
@@ -43,9 +82,36 @@ export default function SignUp() {
|
||||
title="Sign Up - IGNY8"
|
||||
description="Create your IGNY8 account and start building topical authority with AI-powered content"
|
||||
/>
|
||||
<AuthLayout plan={planDetails}>
|
||||
<SignUpFormSimplified planDetails={planDetails} planLoading={planLoading} />
|
||||
</AuthLayout>
|
||||
<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 */}
|
||||
<SignUpFormUnified
|
||||
plans={plans}
|
||||
selectedPlan={selectedPlan}
|
||||
onPlanSelect={setSelectedPlan}
|
||||
plansLoading={plansLoading}
|
||||
/>
|
||||
|
||||
{/* Right Side - Pricing Plans */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-blue-50 to-indigo-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 flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-brand-600 dark:bg-brand-500 rounded-xl">
|
||||
<span className="text-xl font-bold text-white">I</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900 dark:text-white">TailAdmin</span>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,8 +233,45 @@ export const useAuthStore = create<AuthState>()(
|
||||
const tokens = responseData.tokens || {};
|
||||
const userData = responseData.user || data.user;
|
||||
|
||||
const newToken = tokens.access || responseData.access || data.access || null;
|
||||
const newRefreshToken = tokens.refresh || responseData.refresh || data.refresh || null;
|
||||
// Extract tokens with multiple fallbacks
|
||||
// Response format: { success: true, data: { user: {...}, tokens: { access, refresh } } }
|
||||
const newToken =
|
||||
tokens.access ||
|
||||
responseData.access ||
|
||||
data.access ||
|
||||
data.data?.tokens?.access ||
|
||||
data.tokens?.access ||
|
||||
null;
|
||||
|
||||
const newRefreshToken =
|
||||
tokens.refresh ||
|
||||
responseData.refresh ||
|
||||
data.refresh ||
|
||||
data.data?.tokens?.refresh ||
|
||||
data.tokens?.refresh ||
|
||||
null;
|
||||
|
||||
console.log('Registration response parsed:', {
|
||||
hasUserData: !!userData,
|
||||
hasAccessToken: !!newToken,
|
||||
hasRefreshToken: !!newRefreshToken,
|
||||
userEmail: userData?.email,
|
||||
accountId: userData?.account?.id,
|
||||
tokensLocation: tokens.access ? 'tokens.access' :
|
||||
responseData.access ? 'responseData.access' :
|
||||
data.data?.tokens?.access ? 'data.data.tokens.access' : 'not found'
|
||||
});
|
||||
|
||||
if (!newToken || !userData) {
|
||||
console.error('Registration succeeded but missing critical data:', {
|
||||
token: newToken,
|
||||
user: userData,
|
||||
fullResponse: data,
|
||||
parsedTokens: tokens,
|
||||
parsedResponseData: responseData
|
||||
});
|
||||
throw new Error('Registration completed but authentication failed. Please try logging in.');
|
||||
}
|
||||
|
||||
// CRITICAL: Set auth state AND immediately persist to localStorage
|
||||
// This prevents race conditions where navigation happens before persist
|
||||
@@ -268,8 +305,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
}
|
||||
|
||||
console.log('Auth state persisted to localStorage successfully');
|
||||
} catch (e) {
|
||||
console.warn('Failed to persist auth state to localStorage:', e);
|
||||
console.error('CRITICAL: Failed to persist auth state to localStorage:', e);
|
||||
throw new Error('Failed to save login session. Please try again.');
|
||||
}
|
||||
|
||||
// Return user data for success handling
|
||||
|
||||
Reference in New Issue
Block a user