feat(billing): add missing payment methods and configurations

- Added migration to include global payment method configurations for Stripe and PayPal (both disabled).
- Ensured existing payment methods like bank transfer and manual payment are correctly configured.
- Added database constraints and indexes for improved data integrity in billing models.
- Introduced foreign key relationship between CreditTransaction and Payment models.
- Added webhook configuration fields to PaymentMethodConfig for future payment gateway integrations.
- Updated SignUpFormUnified component to handle payment method selection based on user country and plan.
- Implemented PaymentHistory component to display user's payment history with status indicators.
This commit is contained in:
IGNY8 VPS (Salman)
2025-12-09 06:14:44 +00:00
parent 72d0b6b0fd
commit 4d13a57068
36 changed files with 4159 additions and 253 deletions

View File

@@ -0,0 +1,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)'
),
),
]

View File

@@ -249,8 +249,6 @@ class Subscription(models.Model):
'igny8_core_auth.Plan', 'igny8_core_auth.Plan',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='subscriptions', related_name='subscriptions',
null=True,
blank=True,
help_text='Subscription plan (tracks historical plan even if account changes plan)' help_text='Subscription plan (tracks historical plan even if account changes plan)'
) )
stripe_subscription_id = models.CharField( stripe_subscription_id = models.CharField(

View File

@@ -235,6 +235,9 @@ class SiteUserAccessSerializer(serializers.ModelSerializer):
read_only_fields = ['granted_at'] read_only_fields = ['granted_at']
from igny8_core.business.billing.models import PAYMENT_METHOD_CHOICES
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
account = AccountSerializer(read_only=True) account = AccountSerializer(read_only=True)
accessible_sites = serializers.SerializerMethodField() accessible_sites = serializers.SerializerMethodField()
@@ -267,7 +270,7 @@ class RegisterSerializer(serializers.Serializer):
) )
plan_slug = serializers.CharField(max_length=50, required=False) plan_slug = serializers.CharField(max_length=50, required=False)
payment_method = serializers.ChoiceField( payment_method = serializers.ChoiceField(
choices=['stripe', 'paypal', 'bank_transfer', 'local_wallet'], choices=[choice[0] for choice in PAYMENT_METHOD_CHOICES],
default='bank_transfer', default='bank_transfer',
required=False required=False
) )
@@ -291,6 +294,21 @@ class RegisterSerializer(serializers.Serializer):
if 'plan_id' in attrs and attrs.get('plan_id') == '': if 'plan_id' in attrs and attrs.get('plan_id') == '':
attrs['plan_id'] = None 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 return attrs
def create(self, validated_data): def create(self, validated_data):

View File

@@ -46,12 +46,36 @@ class RegisterView(APIView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
def post(self, request): 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) serializer = RegisterSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
user = serializer.save() 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) user_serializer = UserSerializer(user)
return success_response( 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', message='Registration successful',
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
request=request request=request

View File

@@ -139,13 +139,8 @@ class CreditPackageAdmin(admin.ModelAdmin):
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
@admin.register(PaymentMethodConfig) # PaymentMethodConfig admin is in modules/billing/admin.py - do not duplicate
class PaymentMethodConfigAdmin(admin.ModelAdmin): # @admin.register(PaymentMethodConfig)
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']
@admin.register(AccountPaymentMethod) @admin.register(AccountPaymentMethod)
class AccountPaymentMethodAdmin(admin.ModelAdmin): class AccountPaymentMethodAdmin(admin.ModelAdmin):

View 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'),
}

View File

@@ -8,6 +8,16 @@ from django.conf import settings
from igny8_core.auth.models import AccountBaseModel 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): class CreditTransaction(AccountBaseModel):
"""Track all credit transactions (additions, deductions)""" """Track all credit transactions (additions, deductions)"""
TRANSACTION_TYPE_CHOICES = [ TRANSACTION_TYPE_CHOICES = [
@@ -23,11 +33,24 @@ class CreditTransaction(AccountBaseModel):
balance_after = models.IntegerField(help_text="Credit balance after this transaction") balance_after = models.IntegerField(help_text="Credit balance after this transaction")
description = models.CharField(max_length=255) description = models.CharField(max_length=255)
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)") 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( reference_id = models.CharField(
max_length=255, max_length=255,
blank=True, 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) created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@@ -181,6 +204,16 @@ class Invoice(AccountBaseModel):
invoice_number = models.CharField(max_length=50, unique=True, db_index=True) 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 # Amounts
subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0) subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0)
tax = 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) ('refunded', 'Refunded'), # Payment refunded (rare)
] ]
PAYMENT_METHOD_CHOICES = [ # Use centralized payment method choices
('stripe', 'Stripe (Credit/Debit Card)'), PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
('paypal', 'PayPal'),
('bank_transfer', 'Bank Transfer (Manual)'),
('local_wallet', 'Local Wallet (Manual)'),
('manual', 'Manual Payment'),
]
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments') 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') currency = models.CharField(max_length=3, default='USD')
# Status # 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
payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True) payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True)
@@ -367,85 +395,6 @@ class Payment(AccountBaseModel):
def __str__(self): def __str__(self):
return f"Payment {self.id} - {self.get_payment_method_display()} - {self.amount} {self.currency}" 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): class CreditPackage(models.Model):
""" """
@@ -497,12 +446,8 @@ class PaymentMethodConfig(models.Model):
Configure payment methods availability per country Configure payment methods availability per country
Allows enabling/disabling manual payments by region Allows enabling/disabling manual payments by region
""" """
PAYMENT_METHOD_CHOICES = [ # Use centralized choices
('stripe', 'Stripe'), PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
('paypal', 'PayPal'),
('bank_transfer', 'Bank Transfer'),
('local_wallet', 'Local Wallet'),
]
country_code = models.CharField( country_code = models.CharField(
max_length=2, 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_type = models.CharField(max_length=100, blank=True, help_text="E.g., PayTM, PhonePe, etc.")
wallet_id = models.CharField(max_length=255, blank=True) 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 # Order/priority
sort_order = models.IntegerField(default=0) sort_order = models.IntegerField(default=0)
@@ -549,12 +500,8 @@ class AccountPaymentMethod(AccountBaseModel):
Account-scoped payment methods (Stripe/PayPal/manual bank/wallet). Account-scoped payment methods (Stripe/PayPal/manual bank/wallet).
Only metadata/refs are stored here; no secrets. Only metadata/refs are stored here; no secrets.
""" """
PAYMENT_METHOD_CHOICES = [ # Use centralized choices
('stripe', 'Stripe'), PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
('paypal', 'PayPal'),
('bank_transfer', 'Bank Transfer'),
('local_wallet', 'Local Wallet'),
]
type = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True) 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='') display_name = models.CharField(max_length=100, help_text="User-visible label", default='')

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

View File

@@ -17,20 +17,31 @@ class InvoiceService:
@staticmethod @staticmethod
def generate_invoice_number(account: Account) -> str: 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} Format: INV-{ACCOUNT_ID}-{YEAR}{MONTH}-{COUNTER}
""" """
from django.db import transaction
now = timezone.now() now = timezone.now()
prefix = f"INV-{account.id}-{now.year}{now.month:02d}" prefix = f"INV-{account.id}-{now.year}{now.month:02d}"
# Get count of invoices for this account this month # Use atomic transaction with SELECT FOR UPDATE to prevent race conditions
count = Invoice.objects.filter( with transaction.atomic():
# Lock the invoice table for this account/month to get accurate count
count = Invoice.objects.select_for_update().filter(
account=account, account=account,
created_at__year=now.year, created_at__year=now.year,
created_at__month=now.month created_at__month=now.month
).count() ).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 @staticmethod
@transaction.atomic @transaction.atomic
@@ -58,27 +69,42 @@ class InvoiceService:
'snapshot_date': timezone.now().isoformat() '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( invoice = Invoice.objects.create(
account=account, account=account,
subscription=subscription, # Set FK directly
invoice_number=InvoiceService.generate_invoice_number(account), invoice_number=InvoiceService.generate_invoice_number(account),
status='pending', status='pending',
currency='USD', currency=currency,
invoice_date=timezone.now().date(), invoice_date=invoice_date,
due_date=billing_period_end.date(), due_date=due_date,
metadata={ metadata={
'billing_snapshot': billing_snapshot, 'billing_snapshot': billing_snapshot,
'billing_period_start': billing_period_start.isoformat(), 'billing_period_start': billing_period_start.isoformat(),
'billing_period_end': billing_period_end.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( invoice.add_line_item(
description=f"{plan.name} Plan - {billing_period_start.strftime('%b %Y')}", description=f"{plan.name} Plan - {billing_period_start.strftime('%b %Y')}",
quantity=1, quantity=1,
unit_price=plan.price, unit_price=Decimal(str(local_price)),
amount=plan.price amount=Decimal(str(local_price))
) )
invoice.calculate_totals() invoice.calculate_totals()
@@ -95,26 +121,38 @@ class InvoiceService:
""" """
Create invoice for credit package purchase 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( invoice = Invoice.objects.create(
account=account, account=account,
invoice_number=InvoiceService.generate_invoice_number(account), invoice_number=InvoiceService.generate_invoice_number(account),
billing_email=account.billing_email or account.users.filter(role='owner').first().email, billing_email=account.billing_email or account.users.filter(role='owner').first().email,
status='pending', status='pending',
currency='USD', currency=currency,
invoice_date=timezone.now().date(), invoice_date=invoice_date,
due_date=timezone.now().date(), due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET),
metadata={ metadata={
'credit_package_id': credit_package.id, 'credit_package_id': credit_package.id,
'credit_amount': credit_package.credits, '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( invoice.add_line_item(
description=f"{credit_package.name} - {credit_package.credits:,} Credits", description=f"{credit_package.name} - {credit_package.credits:,} Credits",
quantity=1, quantity=1,
unit_price=credit_package.price, unit_price=Decimal(str(local_price)),
amount=credit_package.price amount=Decimal(str(local_price))
) )
invoice.calculate_totals() invoice.calculate_totals()

View 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

View File

@@ -0,0 +1,178 @@
"""
Payment retry mechanism for failed payments
Implements automatic retry logic with exponential backoff
"""
from datetime import timedelta
from django.utils import timezone
from celery import shared_task
from igny8_core.business.billing.models import Payment
from igny8_core.business.billing.config import MAX_PAYMENT_RETRIES
import logging
logger = logging.getLogger(__name__)
@shared_task(name='billing.retry_failed_payment')
def retry_failed_payment(payment_id: int):
"""
Retry a failed payment with exponential backoff
Args:
payment_id: Payment ID to retry
"""
try:
payment = Payment.objects.get(id=payment_id)
# Only retry failed payments
if payment.status != 'failed':
logger.info(f"Payment {payment_id} status is {payment.status}, skipping retry")
return
# Check retry count
retry_count = payment.metadata.get('retry_count', 0)
if retry_count >= MAX_PAYMENT_RETRIES:
logger.warning(f"Payment {payment_id} exceeded max retries ({MAX_PAYMENT_RETRIES})")
payment.metadata['retry_exhausted'] = True
payment.save(update_fields=['metadata'])
return
# Process retry based on payment method
if payment.payment_method == 'stripe':
success = _retry_stripe_payment(payment)
elif payment.payment_method == 'paypal':
success = _retry_paypal_payment(payment)
else:
# Manual payments cannot be automatically retried
logger.info(f"Payment {payment_id} is manual, cannot auto-retry")
return
# Update retry count
retry_count += 1
payment.metadata['retry_count'] = retry_count
payment.metadata['last_retry_at'] = timezone.now().isoformat()
if success:
payment.status = 'succeeded'
payment.processed_at = timezone.now()
payment.failure_reason = ''
logger.info(f"Payment {payment_id} retry succeeded")
else:
# Schedule next retry with exponential backoff
if retry_count < MAX_PAYMENT_RETRIES:
delay_minutes = 5 * (2 ** retry_count) # 5, 10, 20 minutes
retry_failed_payment.apply_async(
args=[payment_id],
countdown=delay_minutes * 60
)
payment.metadata['next_retry_at'] = (
timezone.now() + timedelta(minutes=delay_minutes)
).isoformat()
logger.info(f"Payment {payment_id} retry {retry_count} failed, next retry in {delay_minutes}m")
payment.save(update_fields=['status', 'processed_at', 'failure_reason', 'metadata'])
except Payment.DoesNotExist:
logger.error(f"Payment {payment_id} not found for retry")
except Exception as e:
logger.exception(f"Error retrying payment {payment_id}: {str(e)}")
def _retry_stripe_payment(payment: Payment) -> bool:
"""
Retry Stripe payment
Args:
payment: Payment instance
Returns:
True if retry succeeded, False otherwise
"""
try:
import stripe
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
stripe_client = get_stripe_client()
# Retrieve payment intent
intent = stripe_client.PaymentIntent.retrieve(payment.stripe_payment_intent_id)
# Attempt to confirm the payment intent
if intent.status == 'requires_payment_method':
# Cannot retry without new payment method
payment.failure_reason = 'Requires new payment method'
return False
elif intent.status == 'requires_action':
# Requires customer action (3D Secure)
payment.failure_reason = 'Requires customer authentication'
return False
elif intent.status == 'succeeded':
return True
return False
except Exception as e:
logger.exception(f"Stripe retry error for payment {payment.id}: {str(e)}")
payment.failure_reason = str(e)
return False
def _retry_paypal_payment(payment: Payment) -> bool:
"""
Retry PayPal payment
Args:
payment: Payment instance
Returns:
True if retry succeeded, False otherwise
"""
try:
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
paypal_client = get_paypal_client()
# Check order status
order = paypal_client.orders.get(payment.paypal_order_id)
if order.status == 'APPROVED':
# Attempt to capture
capture_response = paypal_client.orders.capture(payment.paypal_order_id)
if capture_response.status == 'COMPLETED':
payment.paypal_capture_id = capture_response.purchase_units[0].payments.captures[0].id
return True
elif order.status == 'COMPLETED':
return True
payment.failure_reason = f'PayPal order status: {order.status}'
return False
except Exception as e:
logger.exception(f"PayPal retry error for payment {payment.id}: {str(e)}")
payment.failure_reason = str(e)
return False
@shared_task(name='billing.schedule_payment_retries')
def schedule_payment_retries():
"""
Periodic task to identify failed payments and schedule retries
Should be run every 5 minutes via Celery Beat
"""
# Get failed payments from last 24 hours that haven't exhausted retries
cutoff_time = timezone.now() - timedelta(hours=24)
failed_payments = Payment.objects.filter(
status='failed',
failed_at__gte=cutoff_time,
payment_method__in=['stripe', 'paypal'] # Only auto-retry gateway payments
).exclude(
metadata__has_key='retry_exhausted'
)
for payment in failed_payments:
retry_count = payment.metadata.get('retry_count', 0)
if retry_count < MAX_PAYMENT_RETRIES:
# Schedule immediate retry if not already scheduled
if 'next_retry_at' not in payment.metadata:
retry_failed_payment.delay(payment.id)
logger.info(f"Scheduled retry for payment {payment.id}")

View File

@@ -0,0 +1,222 @@
"""
Subscription renewal tasks
Handles automatic subscription renewals with Celery
"""
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from celery import shared_task
from igny8_core.business.billing.models import Subscription, Invoice, Payment
from igny8_core.business.billing.services.invoice_service import InvoiceService
from igny8_core.business.billing.services.email_service import BillingEmailService
from igny8_core.business.billing.config import SUBSCRIPTION_RENEWAL_NOTICE_DAYS
import logging
logger = logging.getLogger(__name__)
@shared_task(name='billing.send_renewal_notices')
def send_renewal_notices():
"""
Send renewal notice emails to subscribers
Run daily to check subscriptions expiring soon
"""
notice_date = timezone.now().date() + timedelta(days=SUBSCRIPTION_RENEWAL_NOTICE_DAYS)
# Get active subscriptions expiring on notice_date
subscriptions = Subscription.objects.filter(
status='active',
current_period_end__date=notice_date
).select_related('account', 'plan', 'account__owner')
for subscription in subscriptions:
# Check if notice already sent
if subscription.metadata.get('renewal_notice_sent'):
continue
try:
BillingEmailService.send_subscription_renewal_notice(
subscription=subscription,
days_until_renewal=SUBSCRIPTION_RENEWAL_NOTICE_DAYS
)
# Mark notice as sent
subscription.metadata['renewal_notice_sent'] = True
subscription.metadata['renewal_notice_sent_at'] = timezone.now().isoformat()
subscription.save(update_fields=['metadata'])
logger.info(f"Renewal notice sent for subscription {subscription.id}")
except Exception as e:
logger.exception(f"Failed to send renewal notice for subscription {subscription.id}: {str(e)}")
@shared_task(name='billing.process_subscription_renewals')
def process_subscription_renewals():
"""
Process subscription renewals for subscriptions ending today
Run daily at midnight to renew subscriptions
"""
today = timezone.now().date()
# Get active subscriptions ending today
subscriptions = Subscription.objects.filter(
status='active',
current_period_end__date=today
).select_related('account', 'plan')
logger.info(f"Processing {subscriptions.count()} subscription renewals for {today}")
for subscription in subscriptions:
try:
renew_subscription(subscription.id)
except Exception as e:
logger.exception(f"Failed to renew subscription {subscription.id}: {str(e)}")
@shared_task(name='billing.renew_subscription')
def renew_subscription(subscription_id: int):
"""
Renew a specific subscription
Args:
subscription_id: Subscription ID to renew
"""
try:
subscription = Subscription.objects.select_related('account', 'plan').get(id=subscription_id)
if subscription.status != 'active':
logger.warning(f"Subscription {subscription_id} is not active, skipping renewal")
return
with transaction.atomic():
# Create renewal invoice
invoice = InvoiceService.create_subscription_invoice(
account=subscription.account,
subscription=subscription
)
# Attempt automatic payment if payment method on file
payment_attempted = False
# Check if account has saved payment method
if subscription.metadata.get('stripe_subscription_id'):
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
elif subscription.metadata.get('paypal_subscription_id'):
payment_attempted = _attempt_paypal_renewal(subscription, invoice)
if payment_attempted:
# Payment processing will handle subscription renewal
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
else:
# No automatic payment - send invoice for manual payment
logger.info(f"Manual payment required for subscription {subscription_id}")
# Mark subscription as pending renewal
subscription.status = 'pending_renewal'
subscription.metadata['renewal_invoice_id'] = invoice.id
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
subscription.save(update_fields=['status', 'metadata'])
# TODO: Send invoice email
# Clear renewal notice flag
if 'renewal_notice_sent' in subscription.metadata:
del subscription.metadata['renewal_notice_sent']
subscription.save(update_fields=['metadata'])
except Subscription.DoesNotExist:
logger.error(f"Subscription {subscription_id} not found for renewal")
except Exception as e:
logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}")
def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool:
"""
Attempt to charge Stripe subscription
Returns:
True if payment initiated, False otherwise
"""
try:
import stripe
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
stripe_client = get_stripe_client()
# Retrieve Stripe subscription
stripe_sub = stripe_client.Subscription.retrieve(
subscription.metadata['stripe_subscription_id']
)
# Create payment intent for renewal
intent = stripe_client.PaymentIntent.create(
amount=int(float(invoice.total_amount) * 100),
currency=invoice.currency.lower(),
customer=stripe_sub.customer,
payment_method=stripe_sub.default_payment_method,
off_session=True,
confirm=True,
metadata={
'invoice_id': invoice.id,
'subscription_id': subscription.id
}
)
# Create payment record
Payment.objects.create(
account=subscription.account,
invoice=invoice,
amount=invoice.total_amount,
currency=invoice.currency,
payment_method='stripe',
status='processing',
stripe_payment_intent_id=intent.id,
metadata={'renewal': True}
)
return True
except Exception as e:
logger.exception(f"Stripe renewal payment failed for subscription {subscription.id}: {str(e)}")
return False
def _attempt_paypal_renewal(subscription: Subscription, invoice: Invoice) -> bool:
"""
Attempt to charge PayPal subscription
Returns:
True if payment initiated, False otherwise
"""
try:
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
paypal_client = get_paypal_client()
# PayPal subscriptions bill automatically
# We just need to verify the subscription is still active
paypal_sub = paypal_client.subscriptions.get(
subscription.metadata['paypal_subscription_id']
)
if paypal_sub.status == 'ACTIVE':
# PayPal will charge automatically, create payment record as processing
Payment.objects.create(
account=subscription.account,
invoice=invoice,
amount=invoice.total_amount,
currency=invoice.currency,
payment_method='paypal',
status='processing',
paypal_order_id=subscription.metadata['paypal_subscription_id'],
metadata={'renewal': True}
)
return True
else:
logger.warning(f"PayPal subscription {paypal_sub.id} status: {paypal_sub.status}")
return False
except Exception as e:
logger.exception(f"PayPal renewal check failed for subscription {subscription.id}: {str(e)}")
return False

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

View File

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

View File

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

View 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': '',
'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}"

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

View File

@@ -33,6 +33,17 @@ class BillingViewSet(viewsets.GenericViewSet):
""" """
permission_classes = [IsAdminOrOwner] 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') @action(detail=False, methods=['post'], url_path='confirm-bank-transfer')
def confirm_bank_transfer(self, request): def confirm_bank_transfer(self, request):
""" """
@@ -182,22 +193,30 @@ class BillingViewSet(viewsets.GenericViewSet):
def list_payment_methods(self, request): def list_payment_methods(self, request):
""" """
Get available payment methods for a specific country. Get available payment methods for a specific country.
Public endpoint - only returns enabled payment methods.
Does not expose sensitive configuration details.
Query params: 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( methods = PaymentMethodConfig.objects.filter(
Q(country_code=country) | Q(country_code='*'), country_code=country,
is_enabled=True is_enabled=True
).order_by('sort_order') ).order_by('sort_order')
# Serialize using the proper serializer
serializer = PaymentMethodConfigSerializer(methods, many=True) 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]) @action(detail=False, methods=['post'], url_path='payments/confirm', permission_classes=[IsAuthenticatedAndActive])
def confirm_payment(self, request): def confirm_payment(self, request):
@@ -237,6 +256,26 @@ class BillingViewSet(viewsets.GenericViewSet):
account=request.account 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 # Validate amount matches invoice
if amount != invoice.total: if amount != invoice.total:
return error_response( return error_response(
@@ -264,8 +303,12 @@ class BillingViewSet(viewsets.GenericViewSet):
f'Reference: {manual_reference}' f'Reference: {manual_reference}'
) )
# TODO: Send notification to admin # Send email notification to user
# send_payment_confirmation_notification(payment) 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( return success_response(
data={ data={
@@ -283,14 +326,20 @@ class BillingViewSet(viewsets.GenericViewSet):
except Invoice.DoesNotExist: except Invoice.DoesNotExist:
return error_response( 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, status_code=status.HTTP_404_NOT_FOUND,
request=request 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: except Exception as e:
logger.error(f'Error confirming payment: {str(e)}', exc_info=True) logger.error(f'Error confirming payment: {str(e)}', exc_info=True)
return error_response( 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, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request request=request
) )
@@ -310,25 +359,66 @@ class BillingViewSet(viewsets.GenericViewSet):
try: try:
with transaction.atomic(): with transaction.atomic():
# Get payment with related objects # Get payment with all related objects to prevent N+1 queries
payment = Payment.objects.select_related( payment = Payment.objects.select_related(
'invoice', 'invoice',
'invoice__subscription', 'invoice__subscription',
'invoice__subscription__plan', 'invoice__subscription__plan',
'account' 'account',
'account__subscription',
'account__subscription__plan',
'account__plan'
).get(id=pk) ).get(id=pk)
if payment.status != 'pending_approval': 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( return error_response(
error=f'Payment is not pending approval (current status: {payment.status})', error=status_msg,
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
request=request request=request
) )
invoice = payment.invoice invoice = payment.invoice
subscription = invoice.subscription
account = payment.account 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 # 1. Update Payment
payment.status = 'succeeded' payment.status = 'succeeded'
payment.approved_by = request.user payment.approved_by = request.user
@@ -354,7 +444,8 @@ class BillingViewSet(viewsets.GenericViewSet):
# 5. Add Credits (if subscription has plan) # 5. Add Credits (if subscription has plan)
credits_added = 0 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 credits_added = subscription.plan.included_credits
# Use CreditService to add credits # Use CreditService to add credits
@@ -371,14 +462,38 @@ class BillingViewSet(viewsets.GenericViewSet):
'approved_by': request.user.email '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( logger.info(
f'Payment approved: Payment {payment.id}, Invoice {invoice.invoice_number}, ' f'Payment approved: Payment {payment.id}, Invoice {invoice.invoice_number}, '
f'Account {account.id} activated, {credits_added} credits added' f'Account {account.id} activated, {credits_added} credits added'
) )
# TODO: Send activation email to user # Send activation email to user
# send_account_activated_email(account, subscription) 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( return success_response(
data={ data={
@@ -399,14 +514,24 @@ class BillingViewSet(viewsets.GenericViewSet):
except Payment.DoesNotExist: except Payment.DoesNotExist:
return error_response( 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, status_code=status.HTTP_404_NOT_FOUND,
request=request request=request
) )
except Exception as e: 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( return error_response(
error=f'Failed to approve payment: {str(e)}', error=error_msg,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request request=request
) )
@@ -441,10 +566,20 @@ class BillingViewSet(viewsets.GenericViewSet):
payment.failure_reason = admin_notes payment.failure_reason = admin_notes
payment.save(update_fields=['status', 'approved_by', 'approved_at', 'failed_at', 'admin_notes', 'failure_reason']) 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}') logger.info(f'Payment rejected: Payment {payment.id}, Reason: {admin_notes}')
# TODO: Send rejection email to user # Send rejection email to user
# send_payment_rejected_email(payment) 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( return success_response(
data={ data={
@@ -459,14 +594,14 @@ class BillingViewSet(viewsets.GenericViewSet):
except Payment.DoesNotExist: except Payment.DoesNotExist:
return error_response( 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, status_code=status.HTTP_404_NOT_FOUND,
request=request request=request
) )
except Exception as e: 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( 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, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request request=request
) )
@@ -504,6 +639,7 @@ class InvoiceViewSet(AccountModelViewSet):
'id': invoice.id, 'id': invoice.id,
'invoice_number': invoice.invoice_number, 'invoice_number': invoice.invoice_number,
'status': invoice.status, 'status': invoice.status,
'total': str(invoice.total), # Alias for compatibility
'total_amount': str(invoice.total), 'total_amount': str(invoice.total),
'subtotal': str(invoice.subtotal), 'subtotal': str(invoice.subtotal),
'tax_amount': str(invoice.tax), 'tax_amount': str(invoice.tax),
@@ -530,6 +666,7 @@ class InvoiceViewSet(AccountModelViewSet):
'id': invoice.id, 'id': invoice.id,
'invoice_number': invoice.invoice_number, 'invoice_number': invoice.invoice_number,
'status': invoice.status, 'status': invoice.status,
'total': str(invoice.total), # Alias for compatibility
'total_amount': str(invoice.total), 'total_amount': str(invoice.total),
'subtotal': str(invoice.subtotal), 'subtotal': str(invoice.subtotal),
'tax_amount': str(invoice.tax), 'tax_amount': str(invoice.tax),
@@ -565,6 +702,17 @@ class PaymentViewSet(AccountModelViewSet):
queryset = Payment.objects.all().select_related('account', 'invoice') queryset = Payment.objects.all().select_related('account', 'invoice')
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
pagination_class = CustomPageNumberPagination 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): def get_queryset(self):
"""Filter payments by account""" """Filter payments by account"""
@@ -605,6 +753,7 @@ class PaymentViewSet(AccountModelViewSet):
'processed_at': payment.processed_at.isoformat() if payment.processed_at else None, 'processed_at': payment.processed_at.isoformat() if payment.processed_at else None,
'manual_reference': payment.manual_reference, 'manual_reference': payment.manual_reference,
'manual_notes': payment.manual_notes, 'manual_notes': payment.manual_notes,
# admin_notes intentionally excluded - internal only
}) })
return paginated_response( return paginated_response(

View File

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

View 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

View File

@@ -125,29 +125,136 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
""" """
Override save_model to set approved_by when status changes to succeeded. Override save_model to trigger approval workflow when status changes to succeeded.
The Payment.save() method will handle all the cascade updates automatically. 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: if obj.status == 'succeeded' and not obj.approved_by:
obj.approved_by = request.user 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) 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): def approve_payments(self, request, queryset):
"""Approve selected manual payments""" """Approve selected manual payments"""
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from igny8_core.business.billing.services.credit_service import CreditService from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.auth.models import Subscription
count = 0 successful = []
errors = [] errors = []
for payment in queryset.filter(status='pending_approval'): for payment in queryset.filter(status='pending_approval'):
try: try:
with transaction.atomic(): with transaction.atomic():
invoice = payment.invoice invoice = payment.invoice
subscription = invoice.subscription if hasattr(invoice, 'subscription') else None
account = payment.account 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 # Update Payment
payment.status = 'succeeded' payment.status = 'succeeded'
payment.approved_by = request.user payment.approved_by = request.user
@@ -172,10 +279,12 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
account.save() account.save()
# Add Credits # 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( CreditService.add_credits(
account=account, account=account,
amount=subscription.plan.included_credits, amount=credits_added,
transaction_type='subscription', transaction_type='subscription',
description=f'{subscription.plan.name} - Invoice {invoice.invoice_number}', description=f'{subscription.plan.name} - Invoice {invoice.invoice_number}',
metadata={ metadata={
@@ -185,17 +294,38 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
'approved_by': request.user.email '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: except Exception as e:
errors.append(f'Payment {payment.id}: {str(e)}') errors.append(f'Payment #{payment.id}: {str(e)}')
if count: # Detailed success message
self.message_user(request, f'Successfully approved {count} payment(s)') 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: if errors:
self.message_user(request, f'✗ Failed to approve {len(errors)} payment(s):', level='ERROR')
for error in errors: 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' approve_payments.short_description = 'Approve selected manual payments'

View File

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

View File

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

View File

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

View File

@@ -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;
"""
),
]

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
""" """
Serializers for Billing Models Serializers for Billing Models
""" """
from typing import Any, Dict, Optional
from decimal import Decimal
from rest_framework import serializers from rest_framework import serializers
from .models import CreditTransaction, CreditUsageLog from .models import CreditTransaction, CreditUsageLog
from igny8_core.auth.models import Account from igny8_core.auth.models import Account
@@ -8,7 +10,11 @@ from igny8_core.business.billing.models import PaymentMethodConfig, Payment
class CreditTransactionSerializer(serializers.ModelSerializer): 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: class Meta:
model = CreditTransaction model = CreditTransaction
@@ -20,7 +26,11 @@ class CreditTransactionSerializer(serializers.ModelSerializer):
class CreditUsageLogSerializer(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: class Meta:
model = CreditUsageLog model = CreditUsageLog
@@ -34,24 +44,27 @@ class CreditUsageLogSerializer(serializers.ModelSerializer):
class CreditBalanceSerializer(serializers.Serializer): class CreditBalanceSerializer(serializers.Serializer):
"""Serializer for credit balance response""" """Serializer for credit balance response"""
credits = serializers.IntegerField() credits: serializers.IntegerField = serializers.IntegerField()
plan_credits_per_month = serializers.IntegerField() plan_credits_per_month: serializers.IntegerField = serializers.IntegerField()
credits_used_this_month = serializers.IntegerField() credits_used_this_month: serializers.IntegerField = serializers.IntegerField()
credits_remaining = serializers.IntegerField() credits_remaining: serializers.IntegerField = serializers.IntegerField()
class UsageSummarySerializer(serializers.Serializer): class UsageSummarySerializer(serializers.Serializer):
"""Serializer for usage summary response""" """Serializer for usage summary response"""
period = serializers.DictField() period: serializers.DictField = serializers.DictField()
total_credits_used = serializers.IntegerField() total_credits_used: serializers.IntegerField = serializers.IntegerField()
total_cost_usd = serializers.DecimalField(max_digits=10, decimal_places=2) total_cost_usd: serializers.DecimalField = serializers.DecimalField(max_digits=10, decimal_places=2)
by_operation = serializers.DictField() by_operation: serializers.DictField = serializers.DictField()
by_model = serializers.DictField() by_model: serializers.DictField = serializers.DictField()
class PaymentMethodConfigSerializer(serializers.ModelSerializer): class PaymentMethodConfigSerializer(serializers.ModelSerializer):
"""Serializer for payment method configuration""" """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: class Meta:
model = PaymentMethodConfig model = PaymentMethodConfig
@@ -66,43 +79,66 @@ class PaymentMethodConfigSerializer(serializers.ModelSerializer):
class PaymentConfirmationSerializer(serializers.Serializer): class PaymentConfirmationSerializer(serializers.Serializer):
"""Serializer for manual payment confirmation""" """Serializer for manual payment confirmation"""
invoice_id = serializers.IntegerField(required=True) invoice_id: serializers.IntegerField = serializers.IntegerField(required=True)
payment_method = serializers.ChoiceField( payment_method: serializers.ChoiceField = serializers.ChoiceField(
choices=['bank_transfer', 'local_wallet'], choices=['bank_transfer', 'local_wallet'],
required=True required=True
) )
manual_reference = serializers.CharField( manual_reference: serializers.CharField = serializers.CharField(
required=True, required=True,
max_length=255, max_length=255,
help_text="Transaction reference number" help_text="Transaction reference number"
) )
manual_notes = serializers.CharField( manual_notes: serializers.CharField = serializers.CharField(
required=False, required=False,
allow_blank=True, allow_blank=True,
help_text="Additional notes about the payment" help_text="Additional notes about the payment"
) )
amount = serializers.DecimalField( amount: serializers.DecimalField = serializers.DecimalField(
max_digits=10, max_digits=10,
decimal_places=2, decimal_places=2,
required=True required=True
) )
proof_url = serializers.URLField( proof_url: serializers.URLField = serializers.URLField(
required=False, required=False,
allow_blank=True, allow_blank=True,
help_text="URL to receipt/proof of payment" 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): class LimitCardSerializer(serializers.Serializer):
"""Serializer for individual limit card""" """Serializer for individual limit card"""
title = serializers.CharField() title: serializers.CharField = serializers.CharField()
limit = serializers.IntegerField() limit: serializers.IntegerField = serializers.IntegerField()
used = serializers.IntegerField() used: serializers.IntegerField = serializers.IntegerField()
available = serializers.IntegerField() available: serializers.IntegerField = serializers.IntegerField()
unit = serializers.CharField() unit: serializers.CharField = serializers.CharField()
category = serializers.CharField() category: serializers.CharField = serializers.CharField()
percentage = serializers.FloatField() percentage: serializers.FloatField = serializers.FloatField()
class UsageLimitsSerializer(serializers.Serializer): class UsageLimitsSerializer(serializers.Serializer):
"""Serializer for usage limits response""" """Serializer for usage limits response"""
limits = LimitCardSerializer(many=True) limits: LimitCardSerializer = LimitCardSerializer(many=True)

View File

@@ -18,6 +18,8 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { addError } = useErrorHandler('ProtectedRoute'); const { addError } = useErrorHandler('ProtectedRoute');
const [showError, setShowError] = useState(false); const [showError, setShowError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [isInitializing, setIsInitializing] = useState(true);
const PLAN_ALLOWED_PATHS = [ const PLAN_ALLOWED_PATHS = [
'/account/plans', '/account/plans',
'/account/purchase-credits', '/account/purchase-credits',
@@ -32,6 +34,15 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
location.pathname.startsWith(prefix) 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 // Track loading state
useEffect(() => { useEffect(() => {
trackLoading('auth-loading', loading); trackLoading('auth-loading', loading);
@@ -82,13 +93,15 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
} }
}, [loading, addError]); }, [loading, addError]);
// Show loading state while checking authentication // Show loading state while checking authentication or initializing
if (loading) { if (loading || isInitializing) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900"> <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="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> <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 && ( {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"> <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) { if (!isAuthenticated) {
console.log('ProtectedRoute: Not authenticated, redirecting to signin');
return <Navigate to="/signin" state={{ from: location }} replace />; return <Navigate to="/signin" state={{ from: location }} replace />;
} }

View File

@@ -38,6 +38,7 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
email: '', email: '',
password: '', password: '',
accountName: '', accountName: '',
billingCountry: 'US', // Default to US for payment method filtering
}); });
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>(''); const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
@@ -91,7 +92,8 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
setPaymentMethodsLoading(true); setPaymentMethodsLoading(true);
try { try {
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api'; 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) { if (!response.ok) {
throw new Error('Failed to load payment methods'); throw new Error('Failed to load payment methods');
@@ -125,7 +127,7 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
}; };
loadPaymentMethods(); loadPaymentMethods();
}, [isPaidPlan]); }, [isPaidPlan, formData.billingCountry]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
@@ -171,6 +173,7 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
registerPayload.payment_method = selectedPaymentMethod; registerPayload.payment_method = selectedPaymentMethod;
// Use email as billing email by default // Use email as billing email by default
registerPayload.billing_email = formData.email; registerPayload.billing_email = formData.email;
registerPayload.billing_country = formData.billingCountry;
} }
const user = await register(registerPayload) as any; const user = await register(registerPayload) as any;
@@ -314,6 +317,31 @@ export default function SignUpFormSimplified({ planDetails: planDetailsProp, pla
{/* Payment Method Selection for Paid Plans */} {/* Payment Method Selection for Paid Plans */}
{isPaidPlan && ( {isPaidPlan && (
<div className="pt-4 border-t border-gray-200 dark:border-gray-700"> <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"> <div className="mb-3">
<Label> <Label>
Payment Method<span className="text-error-500">*</span> Payment Method<span className="text-error-500">*</span>

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

View File

@@ -29,7 +29,8 @@ interface PaymentConfirmationModalProps {
invoice: { invoice: {
id: number; id: number;
invoice_number: string; 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; currency?: string;
}; };
paymentMethod: { paymentMethod: {
@@ -110,6 +111,10 @@ export default function PaymentConfirmationModal({
return; return;
} }
// Create AbortController for timeout handling
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
try { try {
setLoading(true); setLoading(true);
@@ -121,16 +126,19 @@ export default function PaymentConfirmationModal({
...(token && { Authorization: `Bearer ${token}` }), ...(token && { Authorization: `Bearer ${token}` }),
}, },
credentials: 'include', credentials: 'include',
signal: controller.signal,
body: JSON.stringify({ body: JSON.stringify({
invoice_id: invoice.id, invoice_id: invoice.id,
payment_method: paymentMethod.payment_method, 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_reference: formData.manual_reference.trim(),
manual_notes: formData.manual_notes.trim() || undefined, manual_notes: formData.manual_notes.trim() || undefined,
proof_url: formData.proof_url || undefined, proof_url: formData.proof_url || undefined,
}), }),
}); });
clearTimeout(timeoutId);
const data = await response.json(); const data = await response.json();
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
@@ -139,7 +147,7 @@ export default function PaymentConfirmationModal({
setSuccess(true); 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(() => { setTimeout(() => {
onClose(); onClose();
onSuccess?.(); onSuccess?.();
@@ -148,9 +156,14 @@ export default function PaymentConfirmationModal({
setUploadedFile(null); setUploadedFile(null);
setUploadedFileName(''); setUploadedFileName('');
setSuccess(false); setSuccess(false);
}, 2000); }, 5000);
} catch (err: any) { } 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'); setError(err.message || 'Failed to submit payment confirmation');
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -197,7 +210,7 @@ export default function PaymentConfirmationModal({
<div> <div>
<span className="text-gray-600 dark:text-gray-400">Amount:</span> <span className="text-gray-600 dark:text-gray-400">Amount:</span>
<p className="font-semibold text-gray-900 dark:text-white"> <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> </p>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">

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

View File

@@ -15,7 +15,8 @@ import PaymentConfirmationModal from './PaymentConfirmationModal';
interface Invoice { interface Invoice {
id: number; id: number;
invoice_number: string; 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; currency: string;
status: string; status: string;
due_date?: string; due_date?: string;
@@ -65,9 +66,9 @@ export default function PendingPaymentBanner({ className = '' }: PendingPaymentB
if (response.ok && data.success && data.results?.length > 0) { if (response.ok && data.success && data.results?.length > 0) {
setInvoice(data.results[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 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', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -75,8 +76,10 @@ export default function PendingPaymentBanner({ className = '' }: PendingPaymentB
}); });
const pmData = await pmResponse.json(); const pmData = await pmResponse.json();
// API returns array directly from DRF Response // Use public endpoint response format
if (pmResponse.ok && Array.isArray(pmData) && pmData.length > 0) { 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]); 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"> <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. Your account is pending payment. Please complete your payment to activate your subscription.
</p> </p>
<div className="mt-3"> <div className="mt-3 flex gap-2">
<Link to="/account/plans"> <Link to="/account/plans">
<Button variant="primary" size="sm"> <Button variant="primary" size="sm">
View Billing Details Complete Payment
</Button>
</Link>
<Link to="/dashboard">
<Button variant="outline" size="sm">
Go to Dashboard
</Button> </Button>
</Link> </Link>
</div> </div>
@@ -251,13 +259,17 @@ export default function PendingPaymentBanner({ className = '' }: PendingPaymentB
</div> </div>
{/* Payment Confirmation Modal */} {/* Payment Confirmation Modal */}
{showPaymentModal && invoice && paymentMethod && ( {showPaymentModal && invoice && (
<PaymentConfirmationModal <PaymentConfirmationModal
isOpen={showPaymentModal} isOpen={showPaymentModal}
onClose={() => setShowPaymentModal(false)} onClose={() => setShowPaymentModal(false)}
onSuccess={handlePaymentSuccess} onSuccess={handlePaymentSuccess}
invoice={invoice} invoice={invoice}
paymentMethod={paymentMethod} paymentMethod={paymentMethod || {
payment_method: 'bank_transfer',
display_name: 'Bank Transfer',
country_code: 'US'
}}
/> />
)} )}
</> </>

View File

@@ -1,7 +1,30 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import PageMeta from "../../components/common/PageMeta"; import PageMeta from "../../components/common/PageMeta";
import AuthLayout from "./AuthPageLayout"; import SignUpFormUnified from "../../components/auth/SignUpFormUnified";
import SignUpFormSimplified from "../../components/auth/SignUpFormSimplified";
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() { export default function SignUp() {
const planSlug = useMemo(() => { const planSlug = useMemo(() => {
@@ -9,29 +32,45 @@ export default function SignUp() {
return params.get("plan") || ""; return params.get("plan") || "";
}, []); }, []);
const [planDetails, setPlanDetails] = useState<any | null>(null); const [plans, setPlans] = useState<Plan[]>([]);
const [planLoading, setPlanLoading] = useState(false); const [plansLoading, setPlansLoading] = useState(true);
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
useEffect(() => { useEffect(() => {
const fetchPlans = async () => { const fetchPlans = async () => {
if (!planSlug) return; setPlansLoading(true);
setPlanLoading(true);
try { try {
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api"; 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 res = await fetch(`${API_BASE_URL}/v1/auth/plans/`);
const data = await res.json(); const data = await res.json();
const plans = data?.results || []; const allPlans = data?.results || [];
const plan = plans.find((p: any) => p.slug === planSlug);
// 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) { if (plan) {
const features = Array.isArray(plan.features) setSelectedPlan(plan);
? plan.features.map((f: string) => f.charAt(0).toUpperCase() + f.slice(1)) } else {
: []; setSelectedPlan(publicPlans[0] || null);
setPlanDetails({ ...plan, features }); }
} else {
setSelectedPlan(publicPlans[0] || null);
} }
} catch (e) { } catch (e) {
// ignore; SignUpForm will handle lack of plan data gracefully console.error('Failed to load plans:', e);
} finally { } finally {
setPlanLoading(false); setPlansLoading(false);
} }
}; };
fetchPlans(); fetchPlans();
@@ -43,9 +82,36 @@ export default function SignUp() {
title="Sign Up - IGNY8" title="Sign Up - IGNY8"
description="Create your IGNY8 account and start building topical authority with AI-powered content" description="Create your IGNY8 account and start building topical authority with AI-powered content"
/> />
<AuthLayout plan={planDetails}> <div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<SignUpFormSimplified planDetails={planDetails} planLoading={planLoading} /> <div className="flex min-h-screen">
</AuthLayout> {/* 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>
</> </>
); );
} }

View File

@@ -233,8 +233,45 @@ export const useAuthStore = create<AuthState>()(
const tokens = responseData.tokens || {}; const tokens = responseData.tokens || {};
const userData = responseData.user || data.user; const userData = responseData.user || data.user;
const newToken = tokens.access || responseData.access || data.access || null; // Extract tokens with multiple fallbacks
const newRefreshToken = tokens.refresh || responseData.refresh || data.refresh || null; // 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 // CRITICAL: Set auth state AND immediately persist to localStorage
// This prevents race conditions where navigation happens before persist // This prevents race conditions where navigation happens before persist
@@ -268,8 +305,11 @@ export const useAuthStore = create<AuthState>()(
if (newRefreshToken) { if (newRefreshToken) {
localStorage.setItem('refresh_token', newRefreshToken); localStorage.setItem('refresh_token', newRefreshToken);
} }
console.log('Auth state persisted to localStorage successfully');
} catch (e) { } 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 // Return user data for success handling