feat(billing): add missing payment methods and configurations
- Added migration to include global payment method configurations for Stripe and PayPal (both disabled). - Ensured existing payment methods like bank transfer and manual payment are correctly configured. - Added database constraints and indexes for improved data integrity in billing models. - Introduced foreign key relationship between CreditTransaction and Payment models. - Added webhook configuration fields to PaymentMethodConfig for future payment gateway integrations. - Updated SignUpFormUnified component to handle payment method selection based on user country and plan. - Implemented PaymentHistory component to display user's payment history with status indicators.
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# Generated migration to fix subscription constraints
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0011_remove_subscription_payment_method'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add unique constraint on tenant_id at database level
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS igny8_subscriptions_tenant_id_unique
|
||||||
|
ON igny8_subscriptions(tenant_id);
|
||||||
|
""",
|
||||||
|
reverse_sql="""
|
||||||
|
DROP INDEX IF EXISTS igny8_subscriptions_tenant_id_unique;
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
|
||||||
|
# Make plan field required (non-nullable)
|
||||||
|
# First set default plan (ID 1 - Free Plan) for any null values
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="""
|
||||||
|
UPDATE igny8_subscriptions
|
||||||
|
SET plan_id = 1
|
||||||
|
WHERE plan_id IS NULL;
|
||||||
|
""",
|
||||||
|
reverse_sql=migrations.RunSQL.noop
|
||||||
|
),
|
||||||
|
|
||||||
|
# Now alter the field to be non-nullable
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='plan',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='subscriptions',
|
||||||
|
to='igny8_core_auth.plan',
|
||||||
|
help_text='Subscription plan (tracks historical plan even if account changes plan)'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -249,8 +249,6 @@ class Subscription(models.Model):
|
|||||||
'igny8_core_auth.Plan',
|
'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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
37
backend/igny8_core/business/billing/config.py
Normal file
37
backend/igny8_core/business/billing/config.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Billing configuration settings
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Payment Gateway Mode
|
||||||
|
PAYMENT_GATEWAY_MODE = getattr(settings, 'PAYMENT_GATEWAY_MODE', 'sandbox') # 'sandbox' or 'production'
|
||||||
|
|
||||||
|
# Auto-approve payments (development only)
|
||||||
|
AUTO_APPROVE_PAYMENTS = getattr(settings, 'AUTO_APPROVE_PAYMENTS', False)
|
||||||
|
|
||||||
|
# Invoice due date offset (days)
|
||||||
|
INVOICE_DUE_DATE_OFFSET = getattr(settings, 'INVOICE_DUE_DATE_OFFSET', 7)
|
||||||
|
|
||||||
|
# Grace period for payment (days)
|
||||||
|
PAYMENT_GRACE_PERIOD = getattr(settings, 'PAYMENT_GRACE_PERIOD', 7)
|
||||||
|
|
||||||
|
# Maximum payment retry attempts
|
||||||
|
MAX_PAYMENT_RETRIES = getattr(settings, 'MAX_PAYMENT_RETRIES', 3)
|
||||||
|
|
||||||
|
# Subscription renewal advance notice (days)
|
||||||
|
SUBSCRIPTION_RENEWAL_NOTICE_DAYS = getattr(settings, 'SUBSCRIPTION_RENEWAL_NOTICE_DAYS', 7)
|
||||||
|
|
||||||
|
# Default subscription plan slugs
|
||||||
|
DEFAULT_PLAN_SLUGS = {
|
||||||
|
'free': getattr(settings, 'FREE_PLAN_SLUG', 'basic-free'),
|
||||||
|
'starter': getattr(settings, 'STARTER_PLAN_SLUG', 'starter-10'),
|
||||||
|
'professional': getattr(settings, 'PROFESSIONAL_PLAN_SLUG', 'professional-100'),
|
||||||
|
'enterprise': getattr(settings, 'ENTERPRISE_PLAN_SLUG', 'enterprise-unlimited'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Credit package slugs
|
||||||
|
DEFAULT_CREDIT_PACKAGES = {
|
||||||
|
'small': getattr(settings, 'SMALL_CREDIT_PACKAGE_SLUG', 'credits-100'),
|
||||||
|
'medium': getattr(settings, 'MEDIUM_CREDIT_PACKAGE_SLUG', 'credits-500'),
|
||||||
|
'large': getattr(settings, 'LARGE_CREDIT_PACKAGE_SLUG', 'credits-1000'),
|
||||||
|
}
|
||||||
@@ -8,6 +8,16 @@ from django.conf import settings
|
|||||||
from igny8_core.auth.models import AccountBaseModel
|
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='')
|
||||||
|
|||||||
228
backend/igny8_core/business/billing/services/email_service.py
Normal file
228
backend/igny8_core/business/billing/services/email_service.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
Email service for billing notifications
|
||||||
|
"""
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingEmailService:
|
||||||
|
"""Service for sending billing-related emails"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_payment_confirmation_email(payment, account):
|
||||||
|
"""
|
||||||
|
Send email when user submits manual payment for approval
|
||||||
|
"""
|
||||||
|
subject = f'Payment Confirmation Received - Invoice #{payment.invoice.invoice_number}'
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'account_name': account.name,
|
||||||
|
'invoice_number': payment.invoice.invoice_number,
|
||||||
|
'amount': payment.amount,
|
||||||
|
'currency': payment.currency,
|
||||||
|
'payment_method': payment.get_payment_method_display(),
|
||||||
|
'manual_reference': payment.manual_reference,
|
||||||
|
'created_at': payment.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plain text message
|
||||||
|
message = f"""
|
||||||
|
Hi {account.name},
|
||||||
|
|
||||||
|
We have received your payment confirmation for Invoice #{payment.invoice.invoice_number}.
|
||||||
|
|
||||||
|
Payment Details:
|
||||||
|
- Amount: {payment.currency} {payment.amount}
|
||||||
|
- Payment Method: {payment.get_payment_method_display()}
|
||||||
|
- Reference: {payment.manual_reference}
|
||||||
|
- Submitted: {payment.created_at.strftime('%Y-%m-%d %H:%M')}
|
||||||
|
|
||||||
|
Your payment is currently under review. You will receive another email once it has been approved.
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
The Igny8 Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message.strip(),
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[account.billing_email or account.owner.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
logger.info(f'Payment confirmation email sent for Payment {payment.id}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send payment confirmation email: {str(e)}')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_payment_approved_email(payment, account, subscription):
|
||||||
|
"""
|
||||||
|
Send email when payment is approved and account activated
|
||||||
|
"""
|
||||||
|
subject = f'Payment Approved - Account Activated'
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'account_name': account.name,
|
||||||
|
'invoice_number': payment.invoice.invoice_number,
|
||||||
|
'amount': payment.amount,
|
||||||
|
'currency': payment.currency,
|
||||||
|
'plan_name': subscription.plan.name if subscription else 'N/A',
|
||||||
|
'approved_at': payment.approved_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
message = f"""
|
||||||
|
Hi {account.name},
|
||||||
|
|
||||||
|
Great news! Your payment has been approved and your account is now active.
|
||||||
|
|
||||||
|
Payment Details:
|
||||||
|
- Invoice: #{payment.invoice.invoice_number}
|
||||||
|
- Amount: {payment.currency} {payment.amount}
|
||||||
|
- Plan: {subscription.plan.name if subscription else 'N/A'}
|
||||||
|
- Approved: {payment.approved_at.strftime('%Y-%m-%d %H:%M')}
|
||||||
|
|
||||||
|
You can now access all features of your plan. Log in to get started!
|
||||||
|
|
||||||
|
Dashboard: {settings.FRONTEND_URL}/dashboard
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
The Igny8 Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message.strip(),
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[account.billing_email or account.owner.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
logger.info(f'Payment approved email sent for Payment {payment.id}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send payment approved email: {str(e)}')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_payment_rejected_email(payment, account, reason):
|
||||||
|
"""
|
||||||
|
Send email when payment is rejected
|
||||||
|
"""
|
||||||
|
subject = f'Payment Declined - Action Required'
|
||||||
|
|
||||||
|
message = f"""
|
||||||
|
Hi {account.name},
|
||||||
|
|
||||||
|
Unfortunately, we were unable to approve your payment for Invoice #{payment.invoice.invoice_number}.
|
||||||
|
|
||||||
|
Reason: {reason}
|
||||||
|
|
||||||
|
Payment Details:
|
||||||
|
- Invoice: #{payment.invoice.invoice_number}
|
||||||
|
- Amount: {payment.currency} {payment.amount}
|
||||||
|
- Reference: {payment.manual_reference}
|
||||||
|
|
||||||
|
You can retry your payment by logging into your account:
|
||||||
|
{settings.FRONTEND_URL}/billing
|
||||||
|
|
||||||
|
If you have questions, please contact our support team.
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
The Igny8 Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message.strip(),
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[account.billing_email or account.owner.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
logger.info(f'Payment rejected email sent for Payment {payment.id}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send payment rejected email: {str(e)}')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_refund_notification(user, payment, refund_amount, reason):
|
||||||
|
"""
|
||||||
|
Send email when refund is processed
|
||||||
|
"""
|
||||||
|
subject = f'Refund Processed - Invoice #{payment.invoice.invoice_number}'
|
||||||
|
|
||||||
|
message = f"""
|
||||||
|
Hi {user.first_name or user.email},
|
||||||
|
|
||||||
|
Your refund has been processed successfully.
|
||||||
|
|
||||||
|
Refund Details:
|
||||||
|
- Invoice: #{payment.invoice.invoice_number}
|
||||||
|
- Original Amount: {payment.currency} {payment.amount}
|
||||||
|
- Refund Amount: {payment.currency} {refund_amount}
|
||||||
|
- Reason: {reason}
|
||||||
|
- Processed: {payment.refunded_at.strftime('%Y-%m-%d %H:%M')}
|
||||||
|
|
||||||
|
The refund will appear in your original payment method within 5-10 business days.
|
||||||
|
|
||||||
|
If you have any questions, please contact our support team.
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
The Igny8 Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message.strip(),
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[user.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
logger.info(f'Refund notification email sent for Payment {payment.id}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send refund notification email: {str(e)}')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_subscription_renewal_notice(subscription, days_until_renewal):
|
||||||
|
"""
|
||||||
|
Send email reminder before subscription renewal
|
||||||
|
"""
|
||||||
|
subject = f'Subscription Renewal Reminder - {days_until_renewal} Days'
|
||||||
|
|
||||||
|
account = subscription.account
|
||||||
|
user = account.owner
|
||||||
|
|
||||||
|
message = f"""
|
||||||
|
Hi {account.name},
|
||||||
|
|
||||||
|
Your subscription will be renewed in {days_until_renewal} days.
|
||||||
|
|
||||||
|
Subscription Details:
|
||||||
|
- Plan: {subscription.plan.name}
|
||||||
|
- Renewal Date: {subscription.current_period_end.strftime('%Y-%m-%d')}
|
||||||
|
- Amount: {subscription.plan.currency} {subscription.plan.price}
|
||||||
|
|
||||||
|
Your payment method will be charged automatically on the renewal date.
|
||||||
|
|
||||||
|
To manage your subscription or update payment details:
|
||||||
|
{settings.FRONTEND_URL}/billing/subscription
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
The Igny8 Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message.strip(),
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[account.billing_email or user.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
logger.info(f'Renewal notice sent for Subscription {subscription.id}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send renewal notice: {str(e)}')
|
||||||
|
|
||||||
@@ -17,20 +17,31 @@ class InvoiceService:
|
|||||||
@staticmethod
|
@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()
|
||||||
|
|||||||
246
backend/igny8_core/business/billing/services/pdf_service.py
Normal file
246
backend/igny8_core/business/billing/services/pdf_service.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"""
|
||||||
|
Invoice PDF generation service
|
||||||
|
Generates PDF invoices for billing records
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.pagesizes import letter
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.units import inch
|
||||||
|
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
|
||||||
|
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
|
||||||
|
from django.conf import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoicePDFGenerator:
|
||||||
|
"""Generate PDF invoices"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_invoice_pdf(invoice):
|
||||||
|
"""
|
||||||
|
Generate PDF for an invoice
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invoice: Invoice model instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BytesIO: PDF file buffer
|
||||||
|
"""
|
||||||
|
buffer = BytesIO()
|
||||||
|
|
||||||
|
# Create PDF document
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buffer,
|
||||||
|
pagesize=letter,
|
||||||
|
rightMargin=0.75*inch,
|
||||||
|
leftMargin=0.75*inch,
|
||||||
|
topMargin=0.75*inch,
|
||||||
|
bottomMargin=0.75*inch
|
||||||
|
)
|
||||||
|
|
||||||
|
# Container for PDF elements
|
||||||
|
elements = []
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
# Custom styles
|
||||||
|
title_style = ParagraphStyle(
|
||||||
|
'CustomTitle',
|
||||||
|
parent=styles['Heading1'],
|
||||||
|
fontSize=24,
|
||||||
|
textColor=colors.HexColor('#1f2937'),
|
||||||
|
spaceAfter=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
heading_style = ParagraphStyle(
|
||||||
|
'CustomHeading',
|
||||||
|
parent=styles['Heading2'],
|
||||||
|
fontSize=14,
|
||||||
|
textColor=colors.HexColor('#374151'),
|
||||||
|
spaceAfter=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
normal_style = ParagraphStyle(
|
||||||
|
'CustomNormal',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
fontSize=10,
|
||||||
|
textColor=colors.HexColor('#4b5563'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
elements.append(Paragraph('INVOICE', title_style))
|
||||||
|
elements.append(Spacer(1, 0.2*inch))
|
||||||
|
|
||||||
|
# Company info and invoice details side by side
|
||||||
|
company_data = [
|
||||||
|
['<b>From:</b>', f'<b>Invoice #:</b> {invoice.invoice_number}'],
|
||||||
|
[getattr(settings, 'COMPANY_NAME', 'Igny8'), f'<b>Date:</b> {invoice.created_at.strftime("%B %d, %Y")}'],
|
||||||
|
[getattr(settings, 'COMPANY_ADDRESS', ''), f'<b>Due Date:</b> {invoice.due_date.strftime("%B %d, %Y")}'],
|
||||||
|
[getattr(settings, 'COMPANY_EMAIL', settings.DEFAULT_FROM_EMAIL), f'<b>Status:</b> {invoice.status.upper()}'],
|
||||||
|
]
|
||||||
|
|
||||||
|
company_table = Table(company_data, colWidths=[3.5*inch, 3*inch])
|
||||||
|
company_table.setStyle(TableStyle([
|
||||||
|
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 10),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, -1), colors.HexColor('#4b5563')),
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||||
|
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
|
||||||
|
]))
|
||||||
|
elements.append(company_table)
|
||||||
|
elements.append(Spacer(1, 0.3*inch))
|
||||||
|
|
||||||
|
# Bill to section
|
||||||
|
elements.append(Paragraph('<b>Bill To:</b>', heading_style))
|
||||||
|
bill_to_data = [
|
||||||
|
[invoice.account.name],
|
||||||
|
[invoice.account.owner.email],
|
||||||
|
]
|
||||||
|
|
||||||
|
if hasattr(invoice.account, 'billing_email') and invoice.account.billing_email:
|
||||||
|
bill_to_data.append([f'Billing: {invoice.account.billing_email}'])
|
||||||
|
|
||||||
|
for line in bill_to_data:
|
||||||
|
elements.append(Paragraph(line[0], normal_style))
|
||||||
|
|
||||||
|
elements.append(Spacer(1, 0.3*inch))
|
||||||
|
|
||||||
|
# Line items table
|
||||||
|
elements.append(Paragraph('<b>Items:</b>', heading_style))
|
||||||
|
|
||||||
|
# Table header
|
||||||
|
line_items_data = [
|
||||||
|
['Description', 'Quantity', 'Unit Price', 'Amount']
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get line items
|
||||||
|
for item in invoice.line_items.all():
|
||||||
|
line_items_data.append([
|
||||||
|
item.description,
|
||||||
|
str(item.quantity),
|
||||||
|
f'{invoice.currency} {item.unit_price:.2f}',
|
||||||
|
f'{invoice.currency} {item.total_price:.2f}'
|
||||||
|
])
|
||||||
|
|
||||||
|
# Add subtotal, tax, total rows
|
||||||
|
line_items_data.append(['', '', '<b>Subtotal:</b>', f'<b>{invoice.currency} {invoice.subtotal:.2f}</b>'])
|
||||||
|
|
||||||
|
if invoice.tax_amount and invoice.tax_amount > 0:
|
||||||
|
line_items_data.append(['', '', f'Tax ({invoice.tax_rate}%):', f'{invoice.currency} {invoice.tax_amount:.2f}'])
|
||||||
|
|
||||||
|
if invoice.discount_amount and invoice.discount_amount > 0:
|
||||||
|
line_items_data.append(['', '', 'Discount:', f'-{invoice.currency} {invoice.discount_amount:.2f}'])
|
||||||
|
|
||||||
|
line_items_data.append(['', '', '<b>Total:</b>', f'<b>{invoice.currency} {invoice.total_amount:.2f}</b>'])
|
||||||
|
|
||||||
|
# Create table
|
||||||
|
line_items_table = Table(
|
||||||
|
line_items_data,
|
||||||
|
colWidths=[3*inch, 1*inch, 1.25*inch, 1.25*inch]
|
||||||
|
)
|
||||||
|
|
||||||
|
line_items_table.setStyle(TableStyle([
|
||||||
|
# Header row
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f3f4f6')),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#1f2937')),
|
||||||
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||||
|
|
||||||
|
# Body rows
|
||||||
|
('FONTNAME', (0, 1), (-1, -4), 'Helvetica'),
|
||||||
|
('FONTSIZE', (0, 1), (-1, -4), 9),
|
||||||
|
('TEXTCOLOR', (0, 1), (-1, -4), colors.HexColor('#4b5563')),
|
||||||
|
('ROWBACKGROUNDS', (0, 1), (-1, -4), [colors.white, colors.HexColor('#f9fafb')]),
|
||||||
|
|
||||||
|
# Summary rows (last 3-4 rows)
|
||||||
|
('FONTNAME', (0, -4), (-1, -1), 'Helvetica'),
|
||||||
|
('FONTSIZE', (0, -4), (-1, -1), 9),
|
||||||
|
('ALIGN', (2, 0), (2, -1), 'RIGHT'),
|
||||||
|
('ALIGN', (3, 0), (3, -1), 'RIGHT'),
|
||||||
|
|
||||||
|
# Grid
|
||||||
|
('GRID', (0, 0), (-1, -4), 0.5, colors.HexColor('#e5e7eb')),
|
||||||
|
('LINEABOVE', (2, -4), (-1, -4), 1, colors.HexColor('#d1d5db')),
|
||||||
|
('LINEABOVE', (2, -1), (-1, -1), 2, colors.HexColor('#1f2937')),
|
||||||
|
|
||||||
|
# Padding
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||||
|
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
||||||
|
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
||||||
|
]))
|
||||||
|
|
||||||
|
elements.append(line_items_table)
|
||||||
|
elements.append(Spacer(1, 0.4*inch))
|
||||||
|
|
||||||
|
# Payment information
|
||||||
|
if invoice.status == 'paid':
|
||||||
|
elements.append(Paragraph('<b>Payment Information:</b>', heading_style))
|
||||||
|
|
||||||
|
payment = invoice.payments.filter(status='succeeded').first()
|
||||||
|
if payment:
|
||||||
|
payment_info = [
|
||||||
|
f'Payment Method: {payment.get_payment_method_display()}',
|
||||||
|
f'Paid On: {payment.processed_at.strftime("%B %d, %Y")}',
|
||||||
|
]
|
||||||
|
|
||||||
|
if payment.manual_reference:
|
||||||
|
payment_info.append(f'Reference: {payment.manual_reference}')
|
||||||
|
|
||||||
|
for line in payment_info:
|
||||||
|
elements.append(Paragraph(line, normal_style))
|
||||||
|
|
||||||
|
elements.append(Spacer(1, 0.2*inch))
|
||||||
|
|
||||||
|
# Footer / Notes
|
||||||
|
if invoice.notes:
|
||||||
|
elements.append(Spacer(1, 0.2*inch))
|
||||||
|
elements.append(Paragraph('<b>Notes:</b>', heading_style))
|
||||||
|
elements.append(Paragraph(invoice.notes, normal_style))
|
||||||
|
|
||||||
|
# Terms
|
||||||
|
elements.append(Spacer(1, 0.3*inch))
|
||||||
|
elements.append(Paragraph('<b>Terms & Conditions:</b>', heading_style))
|
||||||
|
terms = getattr(settings, 'INVOICE_TERMS', 'Payment is due within 7 days of invoice date.')
|
||||||
|
elements.append(Paragraph(terms, normal_style))
|
||||||
|
|
||||||
|
# Build PDF
|
||||||
|
doc.build(elements)
|
||||||
|
|
||||||
|
# Get PDF content
|
||||||
|
buffer.seek(0)
|
||||||
|
return buffer
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save_invoice_pdf(invoice, file_path=None):
|
||||||
|
"""
|
||||||
|
Generate and save invoice PDF to file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invoice: Invoice model instance
|
||||||
|
file_path: Optional file path, defaults to media/invoices/
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: File path where PDF was saved
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
|
||||||
|
# Generate PDF
|
||||||
|
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
|
||||||
|
|
||||||
|
# Determine file path
|
||||||
|
if not file_path:
|
||||||
|
file_path = f'invoices/{invoice.invoice_number}.pdf'
|
||||||
|
|
||||||
|
# Save to storage
|
||||||
|
saved_path = default_storage.save(file_path, ContentFile(pdf_buffer.read()))
|
||||||
|
|
||||||
|
logger.info(f'Invoice PDF saved: {saved_path}')
|
||||||
|
return saved_path
|
||||||
178
backend/igny8_core/business/billing/tasks/payment_retry.py
Normal file
178
backend/igny8_core/business/billing/tasks/payment_retry.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Payment retry mechanism for failed payments
|
||||||
|
Implements automatic retry logic with exponential backoff
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from celery import shared_task
|
||||||
|
from igny8_core.business.billing.models import Payment
|
||||||
|
from igny8_core.business.billing.config import MAX_PAYMENT_RETRIES
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='billing.retry_failed_payment')
|
||||||
|
def retry_failed_payment(payment_id: int):
|
||||||
|
"""
|
||||||
|
Retry a failed payment with exponential backoff
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment_id: Payment ID to retry
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payment = Payment.objects.get(id=payment_id)
|
||||||
|
|
||||||
|
# Only retry failed payments
|
||||||
|
if payment.status != 'failed':
|
||||||
|
logger.info(f"Payment {payment_id} status is {payment.status}, skipping retry")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check retry count
|
||||||
|
retry_count = payment.metadata.get('retry_count', 0)
|
||||||
|
if retry_count >= MAX_PAYMENT_RETRIES:
|
||||||
|
logger.warning(f"Payment {payment_id} exceeded max retries ({MAX_PAYMENT_RETRIES})")
|
||||||
|
payment.metadata['retry_exhausted'] = True
|
||||||
|
payment.save(update_fields=['metadata'])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process retry based on payment method
|
||||||
|
if payment.payment_method == 'stripe':
|
||||||
|
success = _retry_stripe_payment(payment)
|
||||||
|
elif payment.payment_method == 'paypal':
|
||||||
|
success = _retry_paypal_payment(payment)
|
||||||
|
else:
|
||||||
|
# Manual payments cannot be automatically retried
|
||||||
|
logger.info(f"Payment {payment_id} is manual, cannot auto-retry")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update retry count
|
||||||
|
retry_count += 1
|
||||||
|
payment.metadata['retry_count'] = retry_count
|
||||||
|
payment.metadata['last_retry_at'] = timezone.now().isoformat()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
payment.status = 'succeeded'
|
||||||
|
payment.processed_at = timezone.now()
|
||||||
|
payment.failure_reason = ''
|
||||||
|
logger.info(f"Payment {payment_id} retry succeeded")
|
||||||
|
else:
|
||||||
|
# Schedule next retry with exponential backoff
|
||||||
|
if retry_count < MAX_PAYMENT_RETRIES:
|
||||||
|
delay_minutes = 5 * (2 ** retry_count) # 5, 10, 20 minutes
|
||||||
|
retry_failed_payment.apply_async(
|
||||||
|
args=[payment_id],
|
||||||
|
countdown=delay_minutes * 60
|
||||||
|
)
|
||||||
|
payment.metadata['next_retry_at'] = (
|
||||||
|
timezone.now() + timedelta(minutes=delay_minutes)
|
||||||
|
).isoformat()
|
||||||
|
logger.info(f"Payment {payment_id} retry {retry_count} failed, next retry in {delay_minutes}m")
|
||||||
|
|
||||||
|
payment.save(update_fields=['status', 'processed_at', 'failure_reason', 'metadata'])
|
||||||
|
|
||||||
|
except Payment.DoesNotExist:
|
||||||
|
logger.error(f"Payment {payment_id} not found for retry")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error retrying payment {payment_id}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_stripe_payment(payment: Payment) -> bool:
|
||||||
|
"""
|
||||||
|
Retry Stripe payment
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment: Payment instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if retry succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import stripe
|
||||||
|
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
|
||||||
|
|
||||||
|
stripe_client = get_stripe_client()
|
||||||
|
|
||||||
|
# Retrieve payment intent
|
||||||
|
intent = stripe_client.PaymentIntent.retrieve(payment.stripe_payment_intent_id)
|
||||||
|
|
||||||
|
# Attempt to confirm the payment intent
|
||||||
|
if intent.status == 'requires_payment_method':
|
||||||
|
# Cannot retry without new payment method
|
||||||
|
payment.failure_reason = 'Requires new payment method'
|
||||||
|
return False
|
||||||
|
elif intent.status == 'requires_action':
|
||||||
|
# Requires customer action (3D Secure)
|
||||||
|
payment.failure_reason = 'Requires customer authentication'
|
||||||
|
return False
|
||||||
|
elif intent.status == 'succeeded':
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Stripe retry error for payment {payment.id}: {str(e)}")
|
||||||
|
payment.failure_reason = str(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_paypal_payment(payment: Payment) -> bool:
|
||||||
|
"""
|
||||||
|
Retry PayPal payment
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment: Payment instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if retry succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
|
||||||
|
|
||||||
|
paypal_client = get_paypal_client()
|
||||||
|
|
||||||
|
# Check order status
|
||||||
|
order = paypal_client.orders.get(payment.paypal_order_id)
|
||||||
|
|
||||||
|
if order.status == 'APPROVED':
|
||||||
|
# Attempt to capture
|
||||||
|
capture_response = paypal_client.orders.capture(payment.paypal_order_id)
|
||||||
|
if capture_response.status == 'COMPLETED':
|
||||||
|
payment.paypal_capture_id = capture_response.purchase_units[0].payments.captures[0].id
|
||||||
|
return True
|
||||||
|
elif order.status == 'COMPLETED':
|
||||||
|
return True
|
||||||
|
|
||||||
|
payment.failure_reason = f'PayPal order status: {order.status}'
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"PayPal retry error for payment {payment.id}: {str(e)}")
|
||||||
|
payment.failure_reason = str(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='billing.schedule_payment_retries')
|
||||||
|
def schedule_payment_retries():
|
||||||
|
"""
|
||||||
|
Periodic task to identify failed payments and schedule retries
|
||||||
|
Should be run every 5 minutes via Celery Beat
|
||||||
|
"""
|
||||||
|
# Get failed payments from last 24 hours that haven't exhausted retries
|
||||||
|
cutoff_time = timezone.now() - timedelta(hours=24)
|
||||||
|
|
||||||
|
failed_payments = Payment.objects.filter(
|
||||||
|
status='failed',
|
||||||
|
failed_at__gte=cutoff_time,
|
||||||
|
payment_method__in=['stripe', 'paypal'] # Only auto-retry gateway payments
|
||||||
|
).exclude(
|
||||||
|
metadata__has_key='retry_exhausted'
|
||||||
|
)
|
||||||
|
|
||||||
|
for payment in failed_payments:
|
||||||
|
retry_count = payment.metadata.get('retry_count', 0)
|
||||||
|
if retry_count < MAX_PAYMENT_RETRIES:
|
||||||
|
# Schedule immediate retry if not already scheduled
|
||||||
|
if 'next_retry_at' not in payment.metadata:
|
||||||
|
retry_failed_payment.delay(payment.id)
|
||||||
|
logger.info(f"Scheduled retry for payment {payment.id}")
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Subscription renewal tasks
|
||||||
|
Handles automatic subscription renewals with Celery
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from celery import shared_task
|
||||||
|
from igny8_core.business.billing.models import Subscription, Invoice, Payment
|
||||||
|
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||||
|
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||||
|
from igny8_core.business.billing.config import SUBSCRIPTION_RENEWAL_NOTICE_DAYS
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='billing.send_renewal_notices')
|
||||||
|
def send_renewal_notices():
|
||||||
|
"""
|
||||||
|
Send renewal notice emails to subscribers
|
||||||
|
Run daily to check subscriptions expiring soon
|
||||||
|
"""
|
||||||
|
notice_date = timezone.now().date() + timedelta(days=SUBSCRIPTION_RENEWAL_NOTICE_DAYS)
|
||||||
|
|
||||||
|
# Get active subscriptions expiring on notice_date
|
||||||
|
subscriptions = Subscription.objects.filter(
|
||||||
|
status='active',
|
||||||
|
current_period_end__date=notice_date
|
||||||
|
).select_related('account', 'plan', 'account__owner')
|
||||||
|
|
||||||
|
for subscription in subscriptions:
|
||||||
|
# Check if notice already sent
|
||||||
|
if subscription.metadata.get('renewal_notice_sent'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
BillingEmailService.send_subscription_renewal_notice(
|
||||||
|
subscription=subscription,
|
||||||
|
days_until_renewal=SUBSCRIPTION_RENEWAL_NOTICE_DAYS
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark notice as sent
|
||||||
|
subscription.metadata['renewal_notice_sent'] = True
|
||||||
|
subscription.metadata['renewal_notice_sent_at'] = timezone.now().isoformat()
|
||||||
|
subscription.save(update_fields=['metadata'])
|
||||||
|
|
||||||
|
logger.info(f"Renewal notice sent for subscription {subscription.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to send renewal notice for subscription {subscription.id}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='billing.process_subscription_renewals')
|
||||||
|
def process_subscription_renewals():
|
||||||
|
"""
|
||||||
|
Process subscription renewals for subscriptions ending today
|
||||||
|
Run daily at midnight to renew subscriptions
|
||||||
|
"""
|
||||||
|
today = timezone.now().date()
|
||||||
|
|
||||||
|
# Get active subscriptions ending today
|
||||||
|
subscriptions = Subscription.objects.filter(
|
||||||
|
status='active',
|
||||||
|
current_period_end__date=today
|
||||||
|
).select_related('account', 'plan')
|
||||||
|
|
||||||
|
logger.info(f"Processing {subscriptions.count()} subscription renewals for {today}")
|
||||||
|
|
||||||
|
for subscription in subscriptions:
|
||||||
|
try:
|
||||||
|
renew_subscription(subscription.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to renew subscription {subscription.id}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='billing.renew_subscription')
|
||||||
|
def renew_subscription(subscription_id: int):
|
||||||
|
"""
|
||||||
|
Renew a specific subscription
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subscription_id: Subscription ID to renew
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subscription = Subscription.objects.select_related('account', 'plan').get(id=subscription_id)
|
||||||
|
|
||||||
|
if subscription.status != 'active':
|
||||||
|
logger.warning(f"Subscription {subscription_id} is not active, skipping renewal")
|
||||||
|
return
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Create renewal invoice
|
||||||
|
invoice = InvoiceService.create_subscription_invoice(
|
||||||
|
account=subscription.account,
|
||||||
|
subscription=subscription
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attempt automatic payment if payment method on file
|
||||||
|
payment_attempted = False
|
||||||
|
|
||||||
|
# Check if account has saved payment method
|
||||||
|
if subscription.metadata.get('stripe_subscription_id'):
|
||||||
|
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
|
||||||
|
elif subscription.metadata.get('paypal_subscription_id'):
|
||||||
|
payment_attempted = _attempt_paypal_renewal(subscription, invoice)
|
||||||
|
|
||||||
|
if payment_attempted:
|
||||||
|
# Payment processing will handle subscription renewal
|
||||||
|
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
|
||||||
|
else:
|
||||||
|
# No automatic payment - send invoice for manual payment
|
||||||
|
logger.info(f"Manual payment required for subscription {subscription_id}")
|
||||||
|
|
||||||
|
# Mark subscription as pending renewal
|
||||||
|
subscription.status = 'pending_renewal'
|
||||||
|
subscription.metadata['renewal_invoice_id'] = invoice.id
|
||||||
|
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
|
||||||
|
subscription.save(update_fields=['status', 'metadata'])
|
||||||
|
|
||||||
|
# TODO: Send invoice email
|
||||||
|
|
||||||
|
# Clear renewal notice flag
|
||||||
|
if 'renewal_notice_sent' in subscription.metadata:
|
||||||
|
del subscription.metadata['renewal_notice_sent']
|
||||||
|
subscription.save(update_fields=['metadata'])
|
||||||
|
|
||||||
|
except Subscription.DoesNotExist:
|
||||||
|
logger.error(f"Subscription {subscription_id} not found for renewal")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool:
|
||||||
|
"""
|
||||||
|
Attempt to charge Stripe subscription
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if payment initiated, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import stripe
|
||||||
|
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
|
||||||
|
|
||||||
|
stripe_client = get_stripe_client()
|
||||||
|
|
||||||
|
# Retrieve Stripe subscription
|
||||||
|
stripe_sub = stripe_client.Subscription.retrieve(
|
||||||
|
subscription.metadata['stripe_subscription_id']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create payment intent for renewal
|
||||||
|
intent = stripe_client.PaymentIntent.create(
|
||||||
|
amount=int(float(invoice.total_amount) * 100),
|
||||||
|
currency=invoice.currency.lower(),
|
||||||
|
customer=stripe_sub.customer,
|
||||||
|
payment_method=stripe_sub.default_payment_method,
|
||||||
|
off_session=True,
|
||||||
|
confirm=True,
|
||||||
|
metadata={
|
||||||
|
'invoice_id': invoice.id,
|
||||||
|
'subscription_id': subscription.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create payment record
|
||||||
|
Payment.objects.create(
|
||||||
|
account=subscription.account,
|
||||||
|
invoice=invoice,
|
||||||
|
amount=invoice.total_amount,
|
||||||
|
currency=invoice.currency,
|
||||||
|
payment_method='stripe',
|
||||||
|
status='processing',
|
||||||
|
stripe_payment_intent_id=intent.id,
|
||||||
|
metadata={'renewal': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Stripe renewal payment failed for subscription {subscription.id}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _attempt_paypal_renewal(subscription: Subscription, invoice: Invoice) -> bool:
|
||||||
|
"""
|
||||||
|
Attempt to charge PayPal subscription
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if payment initiated, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
|
||||||
|
|
||||||
|
paypal_client = get_paypal_client()
|
||||||
|
|
||||||
|
# PayPal subscriptions bill automatically
|
||||||
|
# We just need to verify the subscription is still active
|
||||||
|
paypal_sub = paypal_client.subscriptions.get(
|
||||||
|
subscription.metadata['paypal_subscription_id']
|
||||||
|
)
|
||||||
|
|
||||||
|
if paypal_sub.status == 'ACTIVE':
|
||||||
|
# PayPal will charge automatically, create payment record as processing
|
||||||
|
Payment.objects.create(
|
||||||
|
account=subscription.account,
|
||||||
|
invoice=invoice,
|
||||||
|
amount=invoice.total_amount,
|
||||||
|
currency=invoice.currency,
|
||||||
|
payment_method='paypal',
|
||||||
|
status='processing',
|
||||||
|
paypal_order_id=subscription.metadata['paypal_subscription_id'],
|
||||||
|
metadata={'renewal': True}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"PayPal subscription {paypal_sub.id} status: {paypal_sub.status}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"PayPal renewal check failed for subscription {subscription.id}: {str(e)}")
|
||||||
|
return False
|
||||||
299
backend/igny8_core/business/billing/tests/test_concurrency.py
Normal file
299
backend/igny8_core/business/billing/tests/test_concurrency.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
"""
|
||||||
|
Concurrency tests for payment approval
|
||||||
|
Tests race conditions and concurrent approval attempts
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from django.test import TestCase, TransactionTestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from decimal import Decimal
|
||||||
|
from igny8_core.business.billing.models import (
|
||||||
|
Invoice, Payment, Subscription, Plan, Account
|
||||||
|
)
|
||||||
|
from igny8_core.business.billing.views import approve_payment
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import threading
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentApprovalConcurrencyTest(TransactionTestCase):
|
||||||
|
"""Test concurrent payment approval scenarios"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data"""
|
||||||
|
# Create admin user
|
||||||
|
self.admin = User.objects.create_user(
|
||||||
|
email='admin@test.com',
|
||||||
|
password='testpass123',
|
||||||
|
is_staff=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create account
|
||||||
|
self.account = Account.objects.create(
|
||||||
|
name='Test Account',
|
||||||
|
owner=self.admin,
|
||||||
|
credit_balance=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create plan
|
||||||
|
self.plan = Plan.objects.create(
|
||||||
|
name='Test Plan',
|
||||||
|
slug='test-plan',
|
||||||
|
price=Decimal('100.00'),
|
||||||
|
currency='USD',
|
||||||
|
billing_period='monthly',
|
||||||
|
included_credits=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create subscription
|
||||||
|
self.subscription = Subscription.objects.create(
|
||||||
|
account=self.account,
|
||||||
|
plan=self.plan,
|
||||||
|
status='pending_payment'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create invoice
|
||||||
|
self.invoice = Invoice.objects.create(
|
||||||
|
account=self.account,
|
||||||
|
invoice_number='INV-TEST-001',
|
||||||
|
status='pending',
|
||||||
|
subtotal=Decimal('100.00'),
|
||||||
|
total_amount=Decimal('100.00'),
|
||||||
|
currency='USD',
|
||||||
|
invoice_type='subscription'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create payment
|
||||||
|
self.payment = Payment.objects.create(
|
||||||
|
account=self.account,
|
||||||
|
invoice=self.invoice,
|
||||||
|
amount=Decimal('100.00'),
|
||||||
|
currency='USD',
|
||||||
|
payment_method='bank_transfer',
|
||||||
|
status='pending_approval',
|
||||||
|
manual_reference='TEST-REF-001'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_concurrent_approval_attempts(self):
|
||||||
|
"""
|
||||||
|
Test that only one concurrent approval succeeds
|
||||||
|
Multiple admins trying to approve same payment simultaneously
|
||||||
|
"""
|
||||||
|
num_threads = 5
|
||||||
|
success_count = 0
|
||||||
|
failure_count = 0
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def approve_payment_thread(payment_id, admin_user):
|
||||||
|
"""Thread worker to approve payment"""
|
||||||
|
try:
|
||||||
|
# Simulate approval logic with transaction
|
||||||
|
with transaction.atomic():
|
||||||
|
payment = Payment.objects.select_for_update().get(id=payment_id)
|
||||||
|
|
||||||
|
# Check if already approved
|
||||||
|
if payment.status == 'succeeded':
|
||||||
|
return {'success': False, 'reason': 'already_approved'}
|
||||||
|
|
||||||
|
# Approve payment
|
||||||
|
payment.status = 'succeeded'
|
||||||
|
payment.approved_by = admin_user
|
||||||
|
payment.save()
|
||||||
|
|
||||||
|
# Update invoice
|
||||||
|
invoice = payment.invoice
|
||||||
|
invoice.status = 'paid'
|
||||||
|
invoice.save()
|
||||||
|
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
# Create multiple threads attempting approval
|
||||||
|
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||||
|
futures = []
|
||||||
|
for i in range(num_threads):
|
||||||
|
future = executor.submit(approve_payment_thread, self.payment.id, self.admin)
|
||||||
|
futures.append(future)
|
||||||
|
|
||||||
|
# Collect results
|
||||||
|
for future in as_completed(futures):
|
||||||
|
result = future.result()
|
||||||
|
results.append(result)
|
||||||
|
if result.get('success'):
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
failure_count += 1
|
||||||
|
|
||||||
|
# Verify only one approval succeeded
|
||||||
|
self.assertEqual(success_count, 1, "Only one approval should succeed")
|
||||||
|
self.assertEqual(failure_count, num_threads - 1, "Other attempts should fail")
|
||||||
|
|
||||||
|
# Verify final state
|
||||||
|
payment = Payment.objects.get(id=self.payment.id)
|
||||||
|
self.assertEqual(payment.status, 'succeeded')
|
||||||
|
|
||||||
|
invoice = Invoice.objects.get(id=self.invoice.id)
|
||||||
|
self.assertEqual(invoice.status, 'paid')
|
||||||
|
|
||||||
|
def test_payment_and_invoice_consistency(self):
|
||||||
|
"""
|
||||||
|
Test that payment and invoice remain consistent under concurrent operations
|
||||||
|
"""
|
||||||
|
def read_payment_invoice(payment_id):
|
||||||
|
"""Read payment and invoice status"""
|
||||||
|
payment = Payment.objects.get(id=payment_id)
|
||||||
|
invoice = Invoice.objects.get(id=payment.invoice_id)
|
||||||
|
return {
|
||||||
|
'payment_status': payment.status,
|
||||||
|
'invoice_status': invoice.status,
|
||||||
|
'consistent': (
|
||||||
|
(payment.status == 'succeeded' and invoice.status == 'paid') or
|
||||||
|
(payment.status == 'pending_approval' and invoice.status == 'pending')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Approve payment in one thread
|
||||||
|
def approve():
|
||||||
|
with transaction.atomic():
|
||||||
|
payment = Payment.objects.select_for_update().get(id=self.payment.id)
|
||||||
|
payment.status = 'succeeded'
|
||||||
|
payment.save()
|
||||||
|
|
||||||
|
invoice = Invoice.objects.select_for_update().get(id=self.invoice.id)
|
||||||
|
invoice.status = 'paid'
|
||||||
|
invoice.save()
|
||||||
|
|
||||||
|
# Read state in parallel threads
|
||||||
|
results = []
|
||||||
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
# Start approval
|
||||||
|
approval_future = executor.submit(approve)
|
||||||
|
|
||||||
|
# Multiple concurrent reads
|
||||||
|
read_futures = [
|
||||||
|
executor.submit(read_payment_invoice, self.payment.id)
|
||||||
|
for _ in range(20)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Wait for approval
|
||||||
|
approval_future.result()
|
||||||
|
|
||||||
|
# Collect read results
|
||||||
|
for future in as_completed(read_futures):
|
||||||
|
results.append(future.result())
|
||||||
|
|
||||||
|
# All reads should show consistent state
|
||||||
|
for result in results:
|
||||||
|
self.assertTrue(
|
||||||
|
result['consistent'],
|
||||||
|
f"Inconsistent state: payment={result['payment_status']}, invoice={result['invoice_status']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_double_approval_prevention(self):
|
||||||
|
"""
|
||||||
|
Test that payment cannot be approved twice
|
||||||
|
"""
|
||||||
|
# First approval
|
||||||
|
with transaction.atomic():
|
||||||
|
payment = Payment.objects.select_for_update().get(id=self.payment.id)
|
||||||
|
payment.status = 'succeeded'
|
||||||
|
payment.approved_by = self.admin
|
||||||
|
payment.save()
|
||||||
|
|
||||||
|
invoice = payment.invoice
|
||||||
|
invoice.status = 'paid'
|
||||||
|
invoice.save()
|
||||||
|
|
||||||
|
# Attempt second approval
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
payment = Payment.objects.select_for_update().get(id=self.payment.id)
|
||||||
|
|
||||||
|
# Should detect already approved
|
||||||
|
if payment.status == 'succeeded':
|
||||||
|
result = 'already_approved'
|
||||||
|
else:
|
||||||
|
payment.status = 'succeeded'
|
||||||
|
payment.save()
|
||||||
|
result = 'approved'
|
||||||
|
except Exception as e:
|
||||||
|
result = f'error: {str(e)}'
|
||||||
|
|
||||||
|
self.assertEqual(result, 'already_approved', "Second approval should be prevented")
|
||||||
|
|
||||||
|
|
||||||
|
class CreditTransactionConcurrencyTest(TransactionTestCase):
|
||||||
|
"""Test concurrent credit additions/deductions"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.admin = User.objects.create_user(
|
||||||
|
email='admin@test.com',
|
||||||
|
password='testpass123'
|
||||||
|
)
|
||||||
|
self.account = Account.objects.create(
|
||||||
|
name='Test Account',
|
||||||
|
owner=self.admin,
|
||||||
|
credit_balance=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_concurrent_credit_deductions(self):
|
||||||
|
"""
|
||||||
|
Test that concurrent credit deductions maintain correct balance
|
||||||
|
"""
|
||||||
|
initial_balance = self.account.credit_balance
|
||||||
|
deduction_amount = 10
|
||||||
|
num_operations = 20
|
||||||
|
|
||||||
|
def deduct_credits(account_id, amount):
|
||||||
|
"""Deduct credits atomically"""
|
||||||
|
from igny8_core.business.billing.models import CreditTransaction
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
account = Account.objects.select_for_update().get(id=account_id)
|
||||||
|
|
||||||
|
# Check sufficient balance
|
||||||
|
if account.credit_balance < amount:
|
||||||
|
return {'success': False, 'reason': 'insufficient_credits'}
|
||||||
|
|
||||||
|
# Deduct credits
|
||||||
|
account.credit_balance -= amount
|
||||||
|
new_balance = account.credit_balance
|
||||||
|
account.save()
|
||||||
|
|
||||||
|
# Record transaction
|
||||||
|
CreditTransaction.objects.create(
|
||||||
|
account=account,
|
||||||
|
transaction_type='deduction',
|
||||||
|
amount=-amount,
|
||||||
|
balance_after=new_balance,
|
||||||
|
description='Test deduction'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {'success': True, 'new_balance': new_balance}
|
||||||
|
|
||||||
|
# Concurrent deductions
|
||||||
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(deduct_credits, self.account.id, deduction_amount)
|
||||||
|
for _ in range(num_operations)
|
||||||
|
]
|
||||||
|
|
||||||
|
results = [future.result() for future in as_completed(futures)]
|
||||||
|
|
||||||
|
# Verify all succeeded
|
||||||
|
success_count = sum(1 for r in results if r.get('success'))
|
||||||
|
self.assertEqual(success_count, num_operations, "All deductions should succeed")
|
||||||
|
|
||||||
|
# Verify final balance
|
||||||
|
self.account.refresh_from_db()
|
||||||
|
expected_balance = initial_balance - (deduction_amount * num_operations)
|
||||||
|
self.assertEqual(
|
||||||
|
self.account.credit_balance,
|
||||||
|
expected_balance,
|
||||||
|
f"Final balance should be {expected_balance}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
Test payment method filtering by country
|
||||||
|
"""
|
||||||
|
from django.test import TestCase, Client
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from igny8_core.business.billing.models import PaymentMethodConfig
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMethodFilteringTest(TestCase):
|
||||||
|
"""Test payment method filtering by billing country"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Create test payment method configs"""
|
||||||
|
# Global methods (available everywhere)
|
||||||
|
PaymentMethodConfig.objects.create(
|
||||||
|
country_code='*',
|
||||||
|
payment_method='stripe',
|
||||||
|
display_name='Credit/Debit Card',
|
||||||
|
is_enabled=True,
|
||||||
|
sort_order=1,
|
||||||
|
)
|
||||||
|
PaymentMethodConfig.objects.create(
|
||||||
|
country_code='*',
|
||||||
|
payment_method='paypal',
|
||||||
|
display_name='PayPal',
|
||||||
|
is_enabled=True,
|
||||||
|
sort_order=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Country-specific methods
|
||||||
|
PaymentMethodConfig.objects.create(
|
||||||
|
country_code='GB',
|
||||||
|
payment_method='bank_transfer',
|
||||||
|
display_name='Bank Transfer (UK)',
|
||||||
|
is_enabled=True,
|
||||||
|
sort_order=3,
|
||||||
|
)
|
||||||
|
PaymentMethodConfig.objects.create(
|
||||||
|
country_code='IN',
|
||||||
|
payment_method='local_wallet',
|
||||||
|
display_name='UPI/Wallets',
|
||||||
|
is_enabled=True,
|
||||||
|
sort_order=4,
|
||||||
|
)
|
||||||
|
PaymentMethodConfig.objects.create(
|
||||||
|
country_code='PK',
|
||||||
|
payment_method='bank_transfer',
|
||||||
|
display_name='Bank Transfer (Pakistan)',
|
||||||
|
is_enabled=True,
|
||||||
|
sort_order=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Disabled method (should not appear)
|
||||||
|
PaymentMethodConfig.objects.create(
|
||||||
|
country_code='*',
|
||||||
|
payment_method='manual',
|
||||||
|
display_name='Manual',
|
||||||
|
is_enabled=False,
|
||||||
|
sort_order=99,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client = Client()
|
||||||
|
|
||||||
|
def test_filter_payment_methods_by_us(self):
|
||||||
|
"""Test filtering for US country - should get only global methods"""
|
||||||
|
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=US')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self.assertTrue(data['success'])
|
||||||
|
self.assertEqual(len(data['results']), 2) # Only stripe and paypal
|
||||||
|
|
||||||
|
methods = [m['type'] for m in data['results']]
|
||||||
|
self.assertIn('stripe', methods)
|
||||||
|
self.assertIn('paypal', methods)
|
||||||
|
|
||||||
|
def test_filter_payment_methods_by_gb(self):
|
||||||
|
"""Test filtering for GB - should get global + GB-specific"""
|
||||||
|
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=GB')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self.assertTrue(data['success'])
|
||||||
|
self.assertEqual(len(data['results']), 3) # stripe, paypal, bank_transfer(GB)
|
||||||
|
|
||||||
|
methods = [m['type'] for m in data['results']]
|
||||||
|
self.assertIn('stripe', methods)
|
||||||
|
self.assertIn('paypal', methods)
|
||||||
|
self.assertIn('bank_transfer', methods)
|
||||||
|
|
||||||
|
def test_filter_payment_methods_by_in(self):
|
||||||
|
"""Test filtering for IN - should get global + IN-specific"""
|
||||||
|
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=IN')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self.assertTrue(data['success'])
|
||||||
|
self.assertEqual(len(data['results']), 3) # stripe, paypal, local_wallet(IN)
|
||||||
|
|
||||||
|
methods = [m['type'] for m in data['results']]
|
||||||
|
self.assertIn('stripe', methods)
|
||||||
|
self.assertIn('paypal', methods)
|
||||||
|
self.assertIn('local_wallet', methods)
|
||||||
|
|
||||||
|
def test_disabled_methods_not_returned(self):
|
||||||
|
"""Test that disabled payment methods are not included"""
|
||||||
|
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=*')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
methods = [m['type'] for m in data['results']]
|
||||||
|
self.assertNotIn('manual', methods) # Disabled method should not appear
|
||||||
|
|
||||||
|
def test_sort_order_respected(self):
|
||||||
|
\"\"\"Test that payment methods are returned in sort_order\"\"\"
|
||||||
|
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=GB')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify first method has lowest sort_order
|
||||||
|
self.assertEqual(data['results'][0]['type'], 'stripe')
|
||||||
|
self.assertEqual(data['results'][0]['sort_order'], 1)
|
||||||
|
|
||||||
|
def test_default_country_fallback(self):
|
||||||
|
"""Test that missing country parameter defaults to global (*)\"\"\"\n response = self.client.get('/api/v1/billing/admin/payment-methods/')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self.assertTrue(data['success'])
|
||||||
|
# Should get at least global methods
|
||||||
|
methods = [m['type'] for m in data['results']]
|
||||||
|
self.assertIn('stripe', methods)
|
||||||
|
self.assertIn('paypal', methods)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for payment workflow
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from igny8_core.auth.models import Account, Plan, Subscription
|
||||||
|
from igny8_core.business.billing.models import Invoice, Payment
|
||||||
|
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentWorkflowIntegrationTest(TestCase):
|
||||||
|
"""Test complete payment workflow including invoice.subscription FK"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Create test data"""
|
||||||
|
# Create plan
|
||||||
|
self.plan = Plan.objects.create(
|
||||||
|
name='Test Plan',
|
||||||
|
slug='test-plan',
|
||||||
|
price=Decimal('29.00'),
|
||||||
|
included_credits=1000,
|
||||||
|
max_sites=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create account
|
||||||
|
self.account = Account.objects.create(
|
||||||
|
name='Test Account',
|
||||||
|
slug='test-account',
|
||||||
|
status='pending_payment',
|
||||||
|
billing_country='US',
|
||||||
|
billing_email='test@example.com',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='testuser',
|
||||||
|
email='testuser@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
account=self.account,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create subscription
|
||||||
|
billing_period_start = timezone.now()
|
||||||
|
billing_period_end = billing_period_start + timedelta(days=30)
|
||||||
|
|
||||||
|
self.subscription = Subscription.objects.create(
|
||||||
|
account=self.account,
|
||||||
|
plan=self.plan,
|
||||||
|
status='pending_payment',
|
||||||
|
current_period_start=billing_period_start,
|
||||||
|
current_period_end=billing_period_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invoice_subscription_fk_relationship(self):
|
||||||
|
"""Test that invoice.subscription FK works correctly"""
|
||||||
|
# Create invoice via service
|
||||||
|
billing_period_start = timezone.now()
|
||||||
|
billing_period_end = billing_period_start + timedelta(days=30)
|
||||||
|
|
||||||
|
invoice = InvoiceService.create_subscription_invoice(
|
||||||
|
subscription=self.subscription,
|
||||||
|
billing_period_start=billing_period_start,
|
||||||
|
billing_period_end=billing_period_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify FK relationship
|
||||||
|
self.assertIsNotNone(invoice.subscription)
|
||||||
|
self.assertEqual(invoice.subscription.id, self.subscription.id)
|
||||||
|
self.assertEqual(invoice.subscription.plan.id, self.plan.id)
|
||||||
|
|
||||||
|
# Verify can access subscription from invoice
|
||||||
|
self.assertEqual(invoice.subscription.account, self.account)
|
||||||
|
self.assertEqual(invoice.subscription.plan.name, 'Test Plan')
|
||||||
|
|
||||||
|
def test_payment_approval_with_subscription(self):
|
||||||
|
"""Test payment approval workflow uses invoice.subscription"""
|
||||||
|
# Create invoice
|
||||||
|
billing_period_start = timezone.now()
|
||||||
|
billing_period_end = billing_period_start + timedelta(days=30)
|
||||||
|
|
||||||
|
invoice = InvoiceService.create_subscription_invoice(
|
||||||
|
subscription=self.subscription,
|
||||||
|
billing_period_start=billing_period_start,
|
||||||
|
billing_period_end=billing_period_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create payment
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
account=self.account,
|
||||||
|
invoice=invoice,
|
||||||
|
amount=invoice.total,
|
||||||
|
currency='USD',
|
||||||
|
status='pending_approval',
|
||||||
|
payment_method='bank_transfer',
|
||||||
|
manual_reference='TEST-REF-001',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify payment links to invoice which links to subscription
|
||||||
|
self.assertIsNotNone(payment.invoice)
|
||||||
|
self.assertIsNotNone(payment.invoice.subscription)
|
||||||
|
self.assertEqual(payment.invoice.subscription.id, self.subscription.id)
|
||||||
|
|
||||||
|
# Simulate approval workflow
|
||||||
|
payment.status = 'succeeded'
|
||||||
|
payment.approved_by = self.user
|
||||||
|
payment.approved_at = timezone.now()
|
||||||
|
payment.save()
|
||||||
|
|
||||||
|
# Update related records
|
||||||
|
invoice.status = 'paid'
|
||||||
|
invoice.paid_at = timezone.now()
|
||||||
|
invoice.save()
|
||||||
|
|
||||||
|
subscription = invoice.subscription
|
||||||
|
subscription.status = 'active'
|
||||||
|
subscription.save()
|
||||||
|
|
||||||
|
# Verify workflow completed successfully
|
||||||
|
self.assertEqual(payment.status, 'succeeded')
|
||||||
|
self.assertEqual(invoice.status, 'paid')
|
||||||
|
self.assertEqual(subscription.status, 'active')
|
||||||
|
self.assertEqual(subscription.plan.included_credits, 1000)
|
||||||
|
|
||||||
|
def test_subscription_dates_not_null_for_paid_plans(self):
|
||||||
|
"""Test that subscription dates are set for paid plans"""
|
||||||
|
self.assertIsNotNone(self.subscription.current_period_start)
|
||||||
|
self.assertIsNotNone(self.subscription.current_period_end)
|
||||||
|
|
||||||
|
# Verify dates are in future
|
||||||
|
self.assertGreater(self.subscription.current_period_end, self.subscription.current_period_start)
|
||||||
|
|
||||||
|
def test_invoice_currency_based_on_country(self):
|
||||||
|
"""Test that invoice currency is set based on billing country"""
|
||||||
|
# Test US -> USD
|
||||||
|
self.account.billing_country = 'US'
|
||||||
|
self.account.save()
|
||||||
|
|
||||||
|
billing_period_start = timezone.now()
|
||||||
|
billing_period_end = billing_period_start + timedelta(days=30)
|
||||||
|
|
||||||
|
invoice_us = InvoiceService.create_subscription_invoice(
|
||||||
|
subscription=self.subscription,
|
||||||
|
billing_period_start=billing_period_start,
|
||||||
|
billing_period_end=billing_period_end,
|
||||||
|
)
|
||||||
|
self.assertEqual(invoice_us.currency, 'USD')
|
||||||
|
|
||||||
|
# Test GB -> GBP
|
||||||
|
self.account.billing_country = 'GB'
|
||||||
|
self.account.save()
|
||||||
|
|
||||||
|
invoice_gb = InvoiceService.create_subscription_invoice(
|
||||||
|
subscription=self.subscription,
|
||||||
|
billing_period_start=billing_period_start,
|
||||||
|
billing_period_end=billing_period_end,
|
||||||
|
)
|
||||||
|
self.assertEqual(invoice_gb.currency, 'GBP')
|
||||||
|
|
||||||
|
# Test IN -> INR
|
||||||
|
self.account.billing_country = 'IN'
|
||||||
|
self.account.save()
|
||||||
|
|
||||||
|
invoice_in = InvoiceService.create_subscription_invoice(
|
||||||
|
subscription=self.subscription,
|
||||||
|
billing_period_start=billing_period_start,
|
||||||
|
billing_period_end=billing_period_end,
|
||||||
|
)
|
||||||
|
self.assertEqual(invoice_in.currency, 'INR')
|
||||||
|
|
||||||
|
def test_invoice_due_date_grace_period(self):
|
||||||
|
"""Test that invoice due date uses grace period instead of billing_period_end"""
|
||||||
|
billing_period_start = timezone.now()
|
||||||
|
billing_period_end = billing_period_start + timedelta(days=30)
|
||||||
|
|
||||||
|
invoice = InvoiceService.create_subscription_invoice(
|
||||||
|
subscription=self.subscription,
|
||||||
|
billing_period_start=billing_period_start,
|
||||||
|
billing_period_end=billing_period_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify due date is invoice_date + 7 days (grace period)
|
||||||
|
expected_due_date = invoice.invoice_date + timedelta(days=7)
|
||||||
|
self.assertEqual(invoice.due_date, expected_due_date)
|
||||||
|
|
||||||
|
# Verify it's NOT billing_period_end
|
||||||
|
self.assertNotEqual(invoice.due_date, billing_period_end.date())
|
||||||
213
backend/igny8_core/business/billing/utils/currency.py
Normal file
213
backend/igny8_core/business/billing/utils/currency.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Currency utilities for billing
|
||||||
|
Maps countries to their currencies based on Stripe/PayPal standards
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Country to currency mapping (Stripe/PayPal standard format)
|
||||||
|
COUNTRY_CURRENCY_MAP = {
|
||||||
|
# North America
|
||||||
|
'US': 'USD',
|
||||||
|
'CA': 'CAD',
|
||||||
|
'MX': 'MXN',
|
||||||
|
|
||||||
|
# Europe
|
||||||
|
'GB': 'GBP',
|
||||||
|
'DE': 'EUR',
|
||||||
|
'FR': 'EUR',
|
||||||
|
'IT': 'EUR',
|
||||||
|
'ES': 'EUR',
|
||||||
|
'NL': 'EUR',
|
||||||
|
'BE': 'EUR',
|
||||||
|
'AT': 'EUR',
|
||||||
|
'PT': 'EUR',
|
||||||
|
'IE': 'EUR',
|
||||||
|
'GR': 'EUR',
|
||||||
|
'FI': 'EUR',
|
||||||
|
'LU': 'EUR',
|
||||||
|
'CH': 'CHF',
|
||||||
|
'NO': 'NOK',
|
||||||
|
'SE': 'SEK',
|
||||||
|
'DK': 'DKK',
|
||||||
|
'PL': 'PLN',
|
||||||
|
'CZ': 'CZK',
|
||||||
|
'HU': 'HUF',
|
||||||
|
'RO': 'RON',
|
||||||
|
|
||||||
|
# Asia Pacific
|
||||||
|
'IN': 'INR',
|
||||||
|
'PK': 'PKR',
|
||||||
|
'BD': 'BDT',
|
||||||
|
'LK': 'LKR',
|
||||||
|
'JP': 'JPY',
|
||||||
|
'CN': 'CNY',
|
||||||
|
'HK': 'HKD',
|
||||||
|
'SG': 'SGD',
|
||||||
|
'MY': 'MYR',
|
||||||
|
'TH': 'THB',
|
||||||
|
'ID': 'IDR',
|
||||||
|
'PH': 'PHP',
|
||||||
|
'VN': 'VND',
|
||||||
|
'KR': 'KRW',
|
||||||
|
'TW': 'TWD',
|
||||||
|
'AU': 'AUD',
|
||||||
|
'NZ': 'NZD',
|
||||||
|
|
||||||
|
# Middle East
|
||||||
|
'AE': 'AED',
|
||||||
|
'SA': 'SAR',
|
||||||
|
'QA': 'QAR',
|
||||||
|
'KW': 'KWD',
|
||||||
|
'BH': 'BHD',
|
||||||
|
'OM': 'OMR',
|
||||||
|
'IL': 'ILS',
|
||||||
|
'TR': 'TRY',
|
||||||
|
|
||||||
|
# Africa
|
||||||
|
'ZA': 'ZAR',
|
||||||
|
'NG': 'NGN',
|
||||||
|
'KE': 'KES',
|
||||||
|
'EG': 'EGP',
|
||||||
|
'MA': 'MAD',
|
||||||
|
|
||||||
|
# South America
|
||||||
|
'BR': 'BRL',
|
||||||
|
'AR': 'ARS',
|
||||||
|
'CL': 'CLP',
|
||||||
|
'CO': 'COP',
|
||||||
|
'PE': 'PEN',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default currency fallback
|
||||||
|
DEFAULT_CURRENCY = 'USD'
|
||||||
|
|
||||||
|
|
||||||
|
def get_currency_for_country(country_code: str) -> str:
|
||||||
|
"""
|
||||||
|
Get currency code for a given country code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
country_code: ISO 2-letter country code (e.g., 'US', 'GB', 'IN')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Currency code (e.g., 'USD', 'GBP', 'INR')
|
||||||
|
"""
|
||||||
|
if not country_code:
|
||||||
|
return DEFAULT_CURRENCY
|
||||||
|
|
||||||
|
country_code = country_code.upper().strip()
|
||||||
|
return COUNTRY_CURRENCY_MAP.get(country_code, DEFAULT_CURRENCY)
|
||||||
|
|
||||||
|
|
||||||
|
def get_currency_symbol(currency_code: str) -> str:
|
||||||
|
"""
|
||||||
|
Get currency symbol for a given currency code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currency_code: Currency code (e.g., 'USD', 'GBP', 'INR')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Currency symbol (e.g., '$', '£', '₹')
|
||||||
|
"""
|
||||||
|
CURRENCY_SYMBOLS = {
|
||||||
|
'USD': '$',
|
||||||
|
'EUR': '€',
|
||||||
|
'GBP': '£',
|
||||||
|
'INR': '₹',
|
||||||
|
'JPY': '¥',
|
||||||
|
'CNY': '¥',
|
||||||
|
'AUD': 'A$',
|
||||||
|
'CAD': 'C$',
|
||||||
|
'CHF': 'Fr',
|
||||||
|
'SEK': 'kr',
|
||||||
|
'NOK': 'kr',
|
||||||
|
'DKK': 'kr',
|
||||||
|
'PLN': 'zł',
|
||||||
|
'BRL': 'R$',
|
||||||
|
'ZAR': 'R',
|
||||||
|
'AED': 'د.إ',
|
||||||
|
'SAR': 'ر.س',
|
||||||
|
'PKR': '₨',
|
||||||
|
}
|
||||||
|
|
||||||
|
return CURRENCY_SYMBOLS.get(currency_code, currency_code + ' ')
|
||||||
|
|
||||||
|
|
||||||
|
# Currency multipliers for countries with payment methods configured
|
||||||
|
# These represent approximate exchange rates to USD
|
||||||
|
CURRENCY_MULTIPLIERS = {
|
||||||
|
'USD': 1.0, # United States
|
||||||
|
'GBP': 0.79, # United Kingdom
|
||||||
|
'INR': 83.0, # India
|
||||||
|
'PKR': 278.0, # Pakistan
|
||||||
|
'CAD': 1.35, # Canada
|
||||||
|
'AUD': 1.52, # Australia
|
||||||
|
'EUR': 0.92, # Germany, France (Eurozone)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map countries to their multipliers
|
||||||
|
COUNTRY_MULTIPLIERS = {
|
||||||
|
'US': CURRENCY_MULTIPLIERS['USD'],
|
||||||
|
'GB': CURRENCY_MULTIPLIERS['GBP'],
|
||||||
|
'IN': CURRENCY_MULTIPLIERS['INR'],
|
||||||
|
'PK': CURRENCY_MULTIPLIERS['PKR'],
|
||||||
|
'CA': CURRENCY_MULTIPLIERS['CAD'],
|
||||||
|
'AU': CURRENCY_MULTIPLIERS['AUD'],
|
||||||
|
'DE': CURRENCY_MULTIPLIERS['EUR'],
|
||||||
|
'FR': CURRENCY_MULTIPLIERS['EUR'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_currency_multiplier(country_code: str) -> float:
|
||||||
|
"""
|
||||||
|
Get currency multiplier for a given country code.
|
||||||
|
Used to convert USD prices to local currency.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
country_code: ISO 2-letter country code (e.g., 'US', 'GB', 'IN')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Multiplier float (e.g., 1.0 for USD, 83.0 for INR)
|
||||||
|
"""
|
||||||
|
if not country_code:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
country_code = country_code.upper().strip()
|
||||||
|
return COUNTRY_MULTIPLIERS.get(country_code, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_usd_to_local(usd_amount: float, country_code: str) -> float:
|
||||||
|
"""
|
||||||
|
Convert USD amount to local currency for given country.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
usd_amount: Amount in USD
|
||||||
|
country_code: ISO 2-letter country code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Amount in local currency
|
||||||
|
"""
|
||||||
|
multiplier = get_currency_multiplier(country_code)
|
||||||
|
return round(usd_amount * multiplier, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def format_currency(amount: float, country_code: str = 'US') -> str:
|
||||||
|
"""
|
||||||
|
Format amount with appropriate currency symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
amount: Numeric amount
|
||||||
|
country_code: ISO 2-letter country code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string (e.g., '$99.00', '₹8,300.00')
|
||||||
|
"""
|
||||||
|
currency = get_currency_for_country(country_code)
|
||||||
|
symbol = get_currency_symbol(currency)
|
||||||
|
|
||||||
|
# Format with commas for thousands
|
||||||
|
if amount >= 1000:
|
||||||
|
formatted = f"{amount:,.2f}"
|
||||||
|
else:
|
||||||
|
formatted = f"{amount:.2f}"
|
||||||
|
|
||||||
|
return f"{symbol}{formatted}"
|
||||||
186
backend/igny8_core/business/billing/utils/errors.py
Normal file
186
backend/igny8_core/business/billing/utils/errors.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
Standardized Error Response Utilities
|
||||||
|
Ensures consistent error formats across the billing module
|
||||||
|
"""
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode:
|
||||||
|
"""Standardized error codes for billing module"""
|
||||||
|
# Payment errors
|
||||||
|
PAYMENT_NOT_FOUND = 'payment_not_found'
|
||||||
|
PAYMENT_ALREADY_PROCESSED = 'payment_already_processed'
|
||||||
|
PAYMENT_AMOUNT_MISMATCH = 'payment_amount_mismatch'
|
||||||
|
PAYMENT_METHOD_NOT_AVAILABLE = 'payment_method_not_available'
|
||||||
|
|
||||||
|
# Invoice errors
|
||||||
|
INVOICE_NOT_FOUND = 'invoice_not_found'
|
||||||
|
INVOICE_ALREADY_PAID = 'invoice_already_paid'
|
||||||
|
INVOICE_VOIDED = 'invoice_voided'
|
||||||
|
INVOICE_EXPIRED = 'invoice_expired'
|
||||||
|
|
||||||
|
# Subscription errors
|
||||||
|
SUBSCRIPTION_NOT_FOUND = 'subscription_not_found'
|
||||||
|
SUBSCRIPTION_INACTIVE = 'subscription_inactive'
|
||||||
|
SUBSCRIPTION_ALREADY_EXISTS = 'subscription_already_exists'
|
||||||
|
|
||||||
|
# Credit errors
|
||||||
|
INSUFFICIENT_CREDITS = 'insufficient_credits'
|
||||||
|
INVALID_CREDIT_PACKAGE = 'invalid_credit_package'
|
||||||
|
|
||||||
|
# Validation errors
|
||||||
|
VALIDATION_ERROR = 'validation_error'
|
||||||
|
MISSING_REQUIRED_FIELD = 'missing_required_field'
|
||||||
|
INVALID_STATUS_TRANSITION = 'invalid_status_transition'
|
||||||
|
|
||||||
|
# Authorization errors
|
||||||
|
UNAUTHORIZED = 'unauthorized'
|
||||||
|
FORBIDDEN = 'forbidden'
|
||||||
|
|
||||||
|
# System errors
|
||||||
|
INTERNAL_ERROR = 'internal_error'
|
||||||
|
TIMEOUT = 'timeout'
|
||||||
|
RATE_LIMITED = 'rate_limited'
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(
|
||||||
|
message: str,
|
||||||
|
code: str = ErrorCode.INTERNAL_ERROR,
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
field_errors: Optional[Dict[str, List[str]]] = None,
|
||||||
|
status_code: int = status.HTTP_400_BAD_REQUEST
|
||||||
|
) -> Response:
|
||||||
|
"""
|
||||||
|
Create standardized error response
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Human-readable error message
|
||||||
|
code: Error code from ErrorCode class
|
||||||
|
details: Additional error context
|
||||||
|
field_errors: Field-specific validation errors
|
||||||
|
status_code: HTTP status code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DRF Response with standardized error format
|
||||||
|
"""
|
||||||
|
response_data = {
|
||||||
|
'success': False,
|
||||||
|
'error': {
|
||||||
|
'code': code,
|
||||||
|
'message': message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if details:
|
||||||
|
response_data['error']['details'] = details
|
||||||
|
|
||||||
|
if field_errors:
|
||||||
|
response_data['error']['field_errors'] = field_errors
|
||||||
|
|
||||||
|
return Response(response_data, status=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def success_response(
|
||||||
|
data: Any = None,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
status_code: int = status.HTTP_200_OK
|
||||||
|
) -> Response:
|
||||||
|
"""
|
||||||
|
Create standardized success response
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Response data
|
||||||
|
message: Optional success message
|
||||||
|
status_code: HTTP status code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DRF Response with standardized success format
|
||||||
|
"""
|
||||||
|
response_data = {
|
||||||
|
'success': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if message:
|
||||||
|
response_data['message'] = message
|
||||||
|
|
||||||
|
if data is not None:
|
||||||
|
response_data['data'] = data
|
||||||
|
|
||||||
|
return Response(response_data, status=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def validation_error_response(
|
||||||
|
field_errors: Dict[str, List[str]],
|
||||||
|
message: str = 'Validation failed'
|
||||||
|
) -> Response:
|
||||||
|
"""
|
||||||
|
Create validation error response
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_errors: Dictionary mapping field names to error messages
|
||||||
|
message: General validation error message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DRF Response with validation error format
|
||||||
|
"""
|
||||||
|
return error_response(
|
||||||
|
message=message,
|
||||||
|
code=ErrorCode.VALIDATION_ERROR,
|
||||||
|
field_errors=field_errors,
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def not_found_response(
|
||||||
|
resource: str,
|
||||||
|
identifier: Any = None
|
||||||
|
) -> Response:
|
||||||
|
"""
|
||||||
|
Create not found error response
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource: Resource type (e.g., 'Payment', 'Invoice')
|
||||||
|
identifier: Resource identifier (ID, slug, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DRF Response with not found error
|
||||||
|
"""
|
||||||
|
message = f"{resource} not found"
|
||||||
|
if identifier:
|
||||||
|
message += f": {identifier}"
|
||||||
|
|
||||||
|
code_map = {
|
||||||
|
'Payment': ErrorCode.PAYMENT_NOT_FOUND,
|
||||||
|
'Invoice': ErrorCode.INVOICE_NOT_FOUND,
|
||||||
|
'Subscription': ErrorCode.SUBSCRIPTION_NOT_FOUND,
|
||||||
|
}
|
||||||
|
|
||||||
|
return error_response(
|
||||||
|
message=message,
|
||||||
|
code=code_map.get(resource, ErrorCode.INTERNAL_ERROR),
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unauthorized_response(
|
||||||
|
message: str = 'Authentication required'
|
||||||
|
) -> Response:
|
||||||
|
"""Create unauthorized error response"""
|
||||||
|
return error_response(
|
||||||
|
message=message,
|
||||||
|
code=ErrorCode.UNAUTHORIZED,
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def forbidden_response(
|
||||||
|
message: str = 'You do not have permission to perform this action'
|
||||||
|
) -> Response:
|
||||||
|
"""Create forbidden error response"""
|
||||||
|
return error_response(
|
||||||
|
message=message,
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
@@ -33,6 +33,17 @@ class BillingViewSet(viewsets.GenericViewSet):
|
|||||||
"""
|
"""
|
||||||
permission_classes = [IsAdminOrOwner]
|
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(
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Invoice PDF views
|
||||||
|
API endpoints for generating and downloading invoice PDFs
|
||||||
|
"""
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from igny8_core.business.billing.models import Invoice
|
||||||
|
from igny8_core.business.billing.services.pdf_service import InvoicePDFGenerator
|
||||||
|
from igny8_core.business.billing.utils.errors import not_found_response
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def download_invoice_pdf(request, invoice_id):
|
||||||
|
"""
|
||||||
|
Download invoice as PDF
|
||||||
|
|
||||||
|
GET /api/v1/billing/invoices/<id>/pdf/
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
invoice = Invoice.objects.prefetch_related('line_items').get(
|
||||||
|
id=invoice_id,
|
||||||
|
account=request.user.account
|
||||||
|
)
|
||||||
|
except Invoice.DoesNotExist:
|
||||||
|
return not_found_response('Invoice', invoice_id)
|
||||||
|
|
||||||
|
# Generate PDF
|
||||||
|
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
|
||||||
|
|
||||||
|
# Return PDF response
|
||||||
|
response = HttpResponse(pdf_buffer.read(), content_type='application/pdf')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="invoice_{invoice.invoice_number}.pdf"'
|
||||||
|
|
||||||
|
logger.info(f'Invoice PDF downloaded: {invoice.invoice_number} by user {request.user.id}')
|
||||||
|
|
||||||
|
return response
|
||||||
208
backend/igny8_core/business/billing/views/refund_views.py
Normal file
208
backend/igny8_core/business/billing/views/refund_views.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
Refund workflow for payments
|
||||||
|
Handles full and partial refunds with proper accounting
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from igny8_core.business.billing.models import Payment, CreditTransaction, Invoice
|
||||||
|
from igny8_core.business.billing.utils.errors import (
|
||||||
|
error_response, success_response, not_found_response, ErrorCode
|
||||||
|
)
|
||||||
|
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def initiate_refund(request, payment_id):
|
||||||
|
"""
|
||||||
|
Initiate a refund for a payment
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"amount": "50.00", # Optional, defaults to full refund
|
||||||
|
"reason": "Customer requested refund",
|
||||||
|
"refund_credits": true # Whether to deduct credits
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payment = Payment.objects.select_related('invoice', 'account').get(
|
||||||
|
id=payment_id,
|
||||||
|
account=request.user.account
|
||||||
|
)
|
||||||
|
except Payment.DoesNotExist:
|
||||||
|
return not_found_response('Payment', payment_id)
|
||||||
|
|
||||||
|
# Validate payment can be refunded
|
||||||
|
if payment.status != 'succeeded':
|
||||||
|
return error_response(
|
||||||
|
message='Only successful payments can be refunded',
|
||||||
|
code=ErrorCode.INVALID_STATUS_TRANSITION,
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if payment.refunded_at:
|
||||||
|
return error_response(
|
||||||
|
message='This payment has already been refunded',
|
||||||
|
code=ErrorCode.PAYMENT_ALREADY_PROCESSED,
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse refund amount
|
||||||
|
refund_amount = request.data.get('amount')
|
||||||
|
if refund_amount:
|
||||||
|
refund_amount = Decimal(str(refund_amount))
|
||||||
|
if refund_amount > Decimal(payment.amount):
|
||||||
|
return error_response(
|
||||||
|
message=f'Refund amount cannot exceed payment amount ({payment.amount})',
|
||||||
|
code=ErrorCode.VALIDATION_ERROR,
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
refund_amount = Decimal(payment.amount)
|
||||||
|
|
||||||
|
reason = request.data.get('reason', 'Refund requested')
|
||||||
|
refund_credits = request.data.get('refund_credits', True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
# Process refund based on payment method
|
||||||
|
refund_successful = False
|
||||||
|
|
||||||
|
if payment.payment_method == 'stripe':
|
||||||
|
refund_successful = _process_stripe_refund(payment, refund_amount, reason)
|
||||||
|
elif payment.payment_method == 'paypal':
|
||||||
|
refund_successful = _process_paypal_refund(payment, refund_amount, reason)
|
||||||
|
else:
|
||||||
|
# Manual payment refund - mark as refunded
|
||||||
|
refund_successful = True
|
||||||
|
|
||||||
|
if not refund_successful:
|
||||||
|
return error_response(
|
||||||
|
message='Refund processing failed. Please contact support.',
|
||||||
|
code=ErrorCode.INTERNAL_ERROR,
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update payment record
|
||||||
|
payment.status = 'refunded'
|
||||||
|
payment.refunded_at = timezone.now()
|
||||||
|
payment.metadata['refund_amount'] = str(refund_amount)
|
||||||
|
payment.metadata['refund_reason'] = reason
|
||||||
|
payment.save(update_fields=['status', 'refunded_at', 'metadata'])
|
||||||
|
|
||||||
|
# Update invoice if full refund
|
||||||
|
if refund_amount == Decimal(payment.amount):
|
||||||
|
invoice = payment.invoice
|
||||||
|
if invoice.status == 'paid':
|
||||||
|
invoice.status = 'pending'
|
||||||
|
invoice.paid_at = None
|
||||||
|
invoice.save(update_fields=['status', 'paid_at'])
|
||||||
|
|
||||||
|
# Deduct credits if applicable
|
||||||
|
if refund_credits and payment.credit_transactions.exists():
|
||||||
|
for credit_tx in payment.credit_transactions.all():
|
||||||
|
if credit_tx.amount > 0: # Only deduct positive credits
|
||||||
|
# Get current balance
|
||||||
|
account = payment.account
|
||||||
|
current_balance = account.credit_balance
|
||||||
|
|
||||||
|
# Create deduction transaction
|
||||||
|
CreditTransaction.objects.create(
|
||||||
|
account=account,
|
||||||
|
transaction_type='refund',
|
||||||
|
amount=-credit_tx.amount,
|
||||||
|
balance_after=current_balance - credit_tx.amount,
|
||||||
|
description=f'Refund: {reason}',
|
||||||
|
payment=payment,
|
||||||
|
metadata={'original_transaction': credit_tx.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update account balance
|
||||||
|
account.credit_balance -= credit_tx.amount
|
||||||
|
account.save(update_fields=['credit_balance'])
|
||||||
|
|
||||||
|
# Send refund notification email
|
||||||
|
try:
|
||||||
|
BillingEmailService.send_refund_notification(
|
||||||
|
user=payment.account.owner,
|
||||||
|
payment=payment,
|
||||||
|
refund_amount=str(refund_amount),
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send refund email for payment {payment_id}: {str(e)}")
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
'payment_id': payment.id,
|
||||||
|
'refund_amount': str(refund_amount),
|
||||||
|
'status': 'refunded'
|
||||||
|
},
|
||||||
|
message='Refund processed successfully'
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Refund error for payment {payment_id}: {str(e)}")
|
||||||
|
return error_response(
|
||||||
|
message='An error occurred while processing the refund',
|
||||||
|
code=ErrorCode.INTERNAL_ERROR,
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_stripe_refund(payment: Payment, amount: Decimal, reason: str) -> bool:
|
||||||
|
"""Process Stripe refund"""
|
||||||
|
try:
|
||||||
|
import stripe
|
||||||
|
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
|
||||||
|
|
||||||
|
stripe_client = get_stripe_client()
|
||||||
|
|
||||||
|
refund = stripe_client.Refund.create(
|
||||||
|
payment_intent=payment.stripe_payment_intent_id,
|
||||||
|
amount=int(amount * 100), # Convert to cents
|
||||||
|
reason='requested_by_customer',
|
||||||
|
metadata={'reason': reason}
|
||||||
|
)
|
||||||
|
|
||||||
|
payment.metadata['stripe_refund_id'] = refund.id
|
||||||
|
return refund.status == 'succeeded'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Stripe refund failed for payment {payment.id}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _process_paypal_refund(payment: Payment, amount: Decimal, reason: str) -> bool:
|
||||||
|
"""Process PayPal refund"""
|
||||||
|
try:
|
||||||
|
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
|
||||||
|
|
||||||
|
paypal_client = get_paypal_client()
|
||||||
|
|
||||||
|
refund_request = {
|
||||||
|
'amount': {
|
||||||
|
'value': str(amount),
|
||||||
|
'currency_code': payment.currency
|
||||||
|
},
|
||||||
|
'note_to_payer': reason
|
||||||
|
}
|
||||||
|
|
||||||
|
refund = paypal_client.payments.captures.refund(
|
||||||
|
payment.paypal_capture_id,
|
||||||
|
refund_request
|
||||||
|
)
|
||||||
|
|
||||||
|
payment.metadata['paypal_refund_id'] = refund.id
|
||||||
|
return refund.status == 'COMPLETED'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"PayPal refund failed for payment {payment.id}: {str(e)}")
|
||||||
|
return False
|
||||||
@@ -125,29 +125,136 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
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'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Generated migration to add subscription FK to Invoice and fix Payment status default
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def populate_subscription_from_metadata(apps, schema_editor):
|
||||||
|
"""Populate subscription FK from metadata for existing invoices"""
|
||||||
|
# Use raw SQL to avoid model field issues during migration
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
# Get invoices with subscription_id in metadata
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, metadata
|
||||||
|
FROM igny8_invoices
|
||||||
|
WHERE metadata::text LIKE '%subscription_id%'
|
||||||
|
""")
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
for invoice_id, metadata in cursor.fetchall():
|
||||||
|
if metadata and isinstance(metadata, dict) and 'subscription_id' in metadata:
|
||||||
|
try:
|
||||||
|
sub_id = int(metadata['subscription_id'])
|
||||||
|
# Check if subscription exists
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM igny8_subscriptions WHERE id = %s",
|
||||||
|
[sub_id]
|
||||||
|
)
|
||||||
|
if cursor.fetchone():
|
||||||
|
# Update invoice with subscription FK
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE igny8_invoices SET subscription_id = %s WHERE id = %s",
|
||||||
|
[sub_id, invoice_id]
|
||||||
|
)
|
||||||
|
updated_count += 1
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
print(f"Could not populate subscription for invoice {invoice_id}: {e}")
|
||||||
|
|
||||||
|
print(f"Populated subscription FK for {updated_count} invoices")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0007_simplify_payment_statuses'),
|
||||||
|
('igny8_core_auth', '0011_remove_subscription_payment_method'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add subscription FK to Invoice
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='subscription',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text='Subscription this invoice is for (if subscription-based)',
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='invoices',
|
||||||
|
to='igny8_core_auth.subscription'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Populate data
|
||||||
|
migrations.RunPython(
|
||||||
|
populate_subscription_from_metadata,
|
||||||
|
reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
# Fix Payment status default
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('pending_approval', 'Pending Approval'),
|
||||||
|
('succeeded', 'Succeeded'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('refunded', 'Refunded')
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default='pending_approval',
|
||||||
|
max_length=20
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Migration to add missing payment method configurations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def add_missing_payment_methods(apps, schema_editor):
|
||||||
|
"""Add stripe and paypal global configs (disabled) and ensure all configs exist"""
|
||||||
|
PaymentMethodConfig = apps.get_model('billing', 'PaymentMethodConfig')
|
||||||
|
|
||||||
|
# Add global Stripe (disabled - waiting for integration)
|
||||||
|
PaymentMethodConfig.objects.get_or_create(
|
||||||
|
country_code='*',
|
||||||
|
payment_method='stripe',
|
||||||
|
defaults={
|
||||||
|
'is_enabled': False,
|
||||||
|
'display_name': 'Credit/Debit Card (Stripe)',
|
||||||
|
'instructions': 'Stripe payment integration coming soon.',
|
||||||
|
'sort_order': 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add global PayPal (disabled - waiting for integration)
|
||||||
|
PaymentMethodConfig.objects.get_or_create(
|
||||||
|
country_code='*',
|
||||||
|
payment_method='paypal',
|
||||||
|
defaults={
|
||||||
|
'is_enabled': False,
|
||||||
|
'display_name': 'PayPal',
|
||||||
|
'instructions': 'PayPal payment integration coming soon.',
|
||||||
|
'sort_order': 2
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure global bank_transfer exists with good instructions
|
||||||
|
PaymentMethodConfig.objects.get_or_create(
|
||||||
|
country_code='*',
|
||||||
|
payment_method='bank_transfer',
|
||||||
|
defaults={
|
||||||
|
'is_enabled': True,
|
||||||
|
'display_name': 'Bank Transfer',
|
||||||
|
'instructions': 'Bank transfer details will be provided after registration.',
|
||||||
|
'sort_order': 3
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add manual payment as global option
|
||||||
|
PaymentMethodConfig.objects.get_or_create(
|
||||||
|
country_code='*',
|
||||||
|
payment_method='manual',
|
||||||
|
defaults={
|
||||||
|
'is_enabled': True,
|
||||||
|
'display_name': 'Manual Payment (Contact Support)',
|
||||||
|
'instructions': 'Contact support@igny8.com for manual payment arrangements.',
|
||||||
|
'sort_order': 10
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Added/updated payment method configurations")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_added_payment_methods(apps, schema_editor):
|
||||||
|
"""Reverse migration"""
|
||||||
|
PaymentMethodConfig = apps.get_model('billing', 'PaymentMethodConfig')
|
||||||
|
PaymentMethodConfig.objects.filter(
|
||||||
|
country_code='*',
|
||||||
|
payment_method__in=['stripe', 'paypal', 'manual']
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0008_add_invoice_subscription_fk'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
add_missing_payment_methods,
|
||||||
|
reverse_code=remove_added_payment_methods
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Migration to add database constraints and indexes for data integrity
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0009_add_missing_payment_methods'),
|
||||||
|
('igny8_core_auth', '0011_remove_subscription_payment_method'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add DB index on invoice_number for fast lookups
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='invoice_number',
|
||||||
|
field=models.CharField(db_index=True, max_length=50, unique=True),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Add index on Payment.status for filtering
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('pending_approval', 'Pending Approval'),
|
||||||
|
('succeeded', 'Succeeded'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('refunded', 'Refunded')
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default='pending_approval',
|
||||||
|
max_length=20
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Add partial unique index on AccountPaymentMethod for single default per account
|
||||||
|
# This prevents multiple is_default=True per account
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="""
|
||||||
|
CREATE UNIQUE INDEX billing_account_payment_method_single_default
|
||||||
|
ON igny8_account_payment_methods (tenant_id)
|
||||||
|
WHERE is_default = true AND is_enabled = true;
|
||||||
|
""",
|
||||||
|
reverse_sql="""
|
||||||
|
DROP INDEX IF EXISTS billing_account_payment_method_single_default;
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
|
||||||
|
# Add composite index on Payment for common queries
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='payment',
|
||||||
|
index=models.Index(
|
||||||
|
fields=['account', 'status', '-created_at'],
|
||||||
|
name='payment_account_status_created_idx'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated migration to add payment constraints
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0010_add_database_constraints'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add composite unique constraint on manual_reference + tenant_id
|
||||||
|
# This prevents duplicate payment submissions with same reference for an account
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="""
|
||||||
|
CREATE UNIQUE INDEX billing_payment_manual_ref_account_unique
|
||||||
|
ON igny8_payments(tenant_id, manual_reference)
|
||||||
|
WHERE manual_reference != '' AND manual_reference IS NOT NULL;
|
||||||
|
""",
|
||||||
|
reverse_sql="""
|
||||||
|
DROP INDEX IF EXISTS billing_payment_manual_ref_account_unique;
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated migration to add Payment FK to CreditTransaction
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0011_add_manual_reference_constraint'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add payment FK field
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='credittransaction',
|
||||||
|
name='payment',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='credit_transactions',
|
||||||
|
to='billing.payment',
|
||||||
|
help_text='Payment that triggered this credit transaction'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Migrate existing reference_id data to payment FK
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="""
|
||||||
|
UPDATE igny8_credit_transactions ct
|
||||||
|
SET payment_id = (
|
||||||
|
SELECT id FROM igny8_payments p
|
||||||
|
WHERE p.id::text = ct.reference_id
|
||||||
|
AND ct.reference_id ~ '^[0-9]+$'
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE ct.reference_id IS NOT NULL
|
||||||
|
AND ct.reference_id != ''
|
||||||
|
AND ct.reference_id ~ '^[0-9]+$';
|
||||||
|
""",
|
||||||
|
reverse_sql=migrations.RunSQL.noop
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Generated migration to add webhook fields to PaymentMethodConfig
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0012_add_payment_fk_to_credit_transaction'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add webhook configuration fields
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='webhook_url',
|
||||||
|
field=models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text='Webhook URL for payment gateway callbacks (Stripe/PayPal)'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='webhook_secret',
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text='Webhook secret for signature verification'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='api_key',
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text='API key for payment gateway integration'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='api_secret',
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text='API secret for payment gateway integration'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Serializers for Billing Models
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
645
frontend/src/components/auth/SignUpFormUnified.tsx
Normal file
645
frontend/src/components/auth/SignUpFormUnified.tsx
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
/**
|
||||||
|
* Unified Signup Form with Integrated Pricing Selection
|
||||||
|
* Combines free and paid signup flows in one modern interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from '../../icons';
|
||||||
|
import { CreditCard, Building2, Wallet, Check, Loader2, CheckCircle } from 'lucide-react';
|
||||||
|
import Label from '../form/Label';
|
||||||
|
import Input from '../form/input/InputField';
|
||||||
|
import Checkbox from '../form/input/Checkbox';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
import SelectDropdown from '../form/SelectDropdown';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
price: string | number;
|
||||||
|
billing_cycle: string;
|
||||||
|
is_active: boolean;
|
||||||
|
max_users: number;
|
||||||
|
max_sites: number;
|
||||||
|
max_keywords: number;
|
||||||
|
monthly_word_count_limit: number;
|
||||||
|
included_credits: number;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentMethodConfig {
|
||||||
|
id: number;
|
||||||
|
payment_method: string;
|
||||||
|
display_name: string;
|
||||||
|
instructions: string | null;
|
||||||
|
country_code: string;
|
||||||
|
is_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignUpFormUnifiedProps {
|
||||||
|
plans: Plan[];
|
||||||
|
selectedPlan: Plan | null;
|
||||||
|
onPlanSelect: (plan: Plan) => void;
|
||||||
|
plansLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignUpFormUnified({
|
||||||
|
plans,
|
||||||
|
selectedPlan,
|
||||||
|
onPlanSelect,
|
||||||
|
plansLoading,
|
||||||
|
}: SignUpFormUnifiedProps) {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
|
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annually'>('monthly');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
accountName: '',
|
||||||
|
billingCountry: 'US',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState<PaymentMethodConfig[]>([]);
|
||||||
|
const [paymentMethodsLoading, setPaymentMethodsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { register, loading } = useAuthStore();
|
||||||
|
|
||||||
|
const isPaidPlan = selectedPlan && parseFloat(String(selectedPlan.price || 0)) > 0;
|
||||||
|
|
||||||
|
// Update URL when plan changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPlan) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('plan', selectedPlan.slug);
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}
|
||||||
|
}, [selectedPlan]);
|
||||||
|
|
||||||
|
// Load payment methods for paid plans
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPaidPlan) {
|
||||||
|
setPaymentMethods([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPaymentMethods = async () => {
|
||||||
|
setPaymentMethodsLoading(true);
|
||||||
|
try {
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
||||||
|
const country = formData.billingCountry || 'US';
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/billing/payment-configs/payment-methods/?country=${country}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load payment methods');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
let methodsList: PaymentMethodConfig[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
methodsList = data;
|
||||||
|
} else if (data.success && data.data) {
|
||||||
|
methodsList = Array.isArray(data.data) ? data.data : data.data.results || [];
|
||||||
|
} else if (data.results) {
|
||||||
|
methodsList = data.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledMethods = methodsList.filter((m: PaymentMethodConfig) => m.is_enabled);
|
||||||
|
setPaymentMethods(enabledMethods);
|
||||||
|
|
||||||
|
if (enabledMethods.length > 0 && !selectedPaymentMethod) {
|
||||||
|
setSelectedPaymentMethod(enabledMethods[0].payment_method);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load payment methods:', err);
|
||||||
|
// Don't set error for free plans or if payment methods fail to load
|
||||||
|
// Just log it and continue
|
||||||
|
} finally {
|
||||||
|
setPaymentMethodsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPaymentMethods();
|
||||||
|
}, [isPaidPlan, formData.billingCountry]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
|
||||||
|
setError('Please fill in all required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isChecked) {
|
||||||
|
setError('Please agree to the Terms and Conditions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedPlan) {
|
||||||
|
setError('Please select a plan');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPaidPlan && !selectedPaymentMethod) {
|
||||||
|
setError('Please select a payment method');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const username = formData.email.split('@')[0];
|
||||||
|
|
||||||
|
const registerPayload: any = {
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
username: username,
|
||||||
|
first_name: formData.firstName,
|
||||||
|
last_name: formData.lastName,
|
||||||
|
account_name: formData.accountName,
|
||||||
|
plan_slug: selectedPlan.slug,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPaidPlan) {
|
||||||
|
registerPayload.payment_method = selectedPaymentMethod;
|
||||||
|
registerPayload.billing_email = formData.email;
|
||||||
|
registerPayload.billing_country = formData.billingCountry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = (await register(registerPayload)) as any;
|
||||||
|
|
||||||
|
// CRITICAL: Verify auth state is actually set in Zustand store
|
||||||
|
// The register function should have already set isAuthenticated=true
|
||||||
|
const currentAuthState = useAuthStore.getState();
|
||||||
|
|
||||||
|
console.log('Post-registration auth state check:', {
|
||||||
|
isAuthenticated: currentAuthState.isAuthenticated,
|
||||||
|
hasUser: !!currentAuthState.user,
|
||||||
|
hasToken: !!currentAuthState.token,
|
||||||
|
userData: user
|
||||||
|
});
|
||||||
|
|
||||||
|
// If for some reason state wasn't set, force set it again
|
||||||
|
if (!currentAuthState.isAuthenticated || !currentAuthState.user || !currentAuthState.token) {
|
||||||
|
console.error('Auth state not properly set after registration, forcing update...');
|
||||||
|
|
||||||
|
// Extract tokens from user data if available
|
||||||
|
const tokenData = user?.tokens || {};
|
||||||
|
const accessToken = user?.access || tokenData.access || localStorage.getItem('access_token');
|
||||||
|
const refreshToken = user?.refresh || tokenData.refresh || localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
// Force set the state
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: user,
|
||||||
|
token: accessToken,
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
isAuthenticated: true,
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a bit for state to propagate
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final verification before navigation
|
||||||
|
const finalState = useAuthStore.getState();
|
||||||
|
if (!finalState.isAuthenticated) {
|
||||||
|
throw new Error('Failed to authenticate after registration. Please try logging in manually.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = user?.account?.status;
|
||||||
|
if (status === 'pending_payment') {
|
||||||
|
navigate('/account/plans', { replace: true });
|
||||||
|
} else {
|
||||||
|
navigate('/sites', { replace: true });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Registration failed. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaymentIcon = (method: string) => {
|
||||||
|
switch (method) {
|
||||||
|
case 'stripe':
|
||||||
|
return <CreditCard className="w-5 h-5" />;
|
||||||
|
case 'bank_transfer':
|
||||||
|
return <Building2 className="w-5 h-5" />;
|
||||||
|
case 'local_wallet':
|
||||||
|
return <Wallet className="w-5 h-5" />;
|
||||||
|
default:
|
||||||
|
return <CreditCard className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
|
||||||
|
return num.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFeatures = (plan: Plan): string[] => {
|
||||||
|
const features: string[] = [];
|
||||||
|
features.push(`${plan.max_sites} ${plan.max_sites === 1 ? 'Site' : 'Sites'}`);
|
||||||
|
features.push(`${plan.max_users} ${plan.max_users === 1 ? 'User' : 'Users'}`);
|
||||||
|
features.push(`${formatNumber(plan.max_keywords || 0)} Keywords`);
|
||||||
|
features.push(`${formatNumber(plan.monthly_word_count_limit || 0)} Words/Month`);
|
||||||
|
features.push(`${formatNumber(plan.included_credits || 0)} AI Credits`);
|
||||||
|
return features;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayPrice = (plan: Plan): number => {
|
||||||
|
const monthlyPrice = typeof plan.price === 'number' ? plan.price : parseFloat(String(plan.price || 0));
|
||||||
|
if (billingPeriod === 'annually') {
|
||||||
|
return monthlyPrice * 12 * 0.85; // 15% discount
|
||||||
|
}
|
||||||
|
return monthlyPrice;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col lg:w-1/2 w-full">
|
||||||
|
{/* Mobile Pricing Toggle */}
|
||||||
|
<div className="lg:hidden bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="relative inline-flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg shadow-sm">
|
||||||
|
<span
|
||||||
|
className={`absolute top-1/2 left-1 flex h-9 w-28 -translate-y-1/2 rounded-md bg-gradient-to-br from-brand-500 to-brand-600 shadow-md transition-all duration-300 ease-out ${
|
||||||
|
billingPeriod === 'monthly' ? 'translate-x-0' : 'translate-x-28'
|
||||||
|
}`}
|
||||||
|
></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBillingPeriod('monthly')}
|
||||||
|
className={`relative flex h-9 w-28 items-center justify-center text-sm font-semibold transition-all duration-200 rounded-md ${
|
||||||
|
billingPeriod === 'monthly' ? 'text-white' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBillingPeriod('annually')}
|
||||||
|
className={`relative flex h-9 w-28 items-center justify-center text-sm font-semibold transition-all duration-200 rounded-md ${
|
||||||
|
billingPeriod === 'annually' ? 'text-white' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Annually
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 flex items-center justify-center">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 font-semibold bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded-full transition-opacity duration-200 ${
|
||||||
|
billingPeriod === 'annually' ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Save 15%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto no-scrollbar flex items-center">
|
||||||
|
<div className="w-full max-w-md mx-auto p-6 sm:p-8">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-5" />
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="mb-2 font-semibold text-gray-800 dark:text-white text-2xl">Sign Up for {selectedPlan?.name || 'IGNY8'}</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Complete your registration and select a payment method.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan Selection - Mobile */}
|
||||||
|
<div className="lg:hidden mb-6">
|
||||||
|
<Label>Select Plan</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const displayPrice = getDisplayPrice(plan);
|
||||||
|
const isSelected = selectedPlan?.id === plan.id;
|
||||||
|
const isFree = parseFloat(String(plan.price || 0)) === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={plan.id}
|
||||||
|
onClick={() => onPlanSelect(plan)}
|
||||||
|
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className={`font-semibold text-sm ${isSelected ? 'text-brand-600 dark:text-brand-400' : 'text-gray-900 dark:text-white'}`}>
|
||||||
|
{plan.name}
|
||||||
|
</span>
|
||||||
|
{isSelected && <CheckCircle className="w-4 h-4 text-brand-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{isFree ? 'Free' : `$${displayPrice.toFixed(2)}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{billingPeriod === 'annually' && !isFree ? '/year' : '/month'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
First Name<span className="text-error-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input type="text" name="firstName" value={formData.firstName} onChange={handleChange} placeholder="Enter your first name" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
Last Name<span className="text-error-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input type="text" name="lastName" value={formData.lastName} onChange={handleChange} placeholder="Enter your last name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
Email<span className="text-error-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input type="email" name="email" value={formData.email} onChange={handleChange} placeholder="Enter your email" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Account Name (optional)</Label>
|
||||||
|
<Input type="text" name="accountName" value={formData.accountName} onChange={handleChange} placeholder="Workspace / Company name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
Password<span className="text-error-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input placeholder="Enter your password" type={showPassword ? 'text' : 'password'} name="password" value={formData.password} onChange={handleChange} />
|
||||||
|
<span onClick={() => setShowPassword(!showPassword)} className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2">
|
||||||
|
{showPassword ? <EyeIcon className="fill-gray-500 dark:fill-gray-400 size-5" /> : <EyeCloseIcon className="fill-gray-500 dark:fill-gray-400 size-5" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPaidPlan && (
|
||||||
|
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
Country<span className="text-error-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<SelectDropdown
|
||||||
|
options={[
|
||||||
|
{ value: 'US', label: '🇺🇸 United States' },
|
||||||
|
{ value: 'GB', label: '🇬🇧 United Kingdom' },
|
||||||
|
{ value: 'IN', label: '🇮🇳 India' },
|
||||||
|
{ value: 'PK', label: '🇵🇰 Pakistan' },
|
||||||
|
{ value: 'CA', label: '🇨🇦 Canada' },
|
||||||
|
{ value: 'AU', label: '🇦🇺 Australia' },
|
||||||
|
{ value: 'DE', label: '🇩🇪 Germany' },
|
||||||
|
{ value: 'FR', label: '🇫🇷 France' },
|
||||||
|
]}
|
||||||
|
value={formData.billingCountry}
|
||||||
|
onChange={(value) => setFormData((prev) => ({ ...prev, billingCountry: value }))}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Payment methods will be filtered by your country</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
Payment Method<span className="text-error-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-2">Select how you'd like to pay for your subscription</p>
|
||||||
|
|
||||||
|
{paymentMethodsLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-6 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-brand-500 mr-2" />
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">Loading payment options...</span>
|
||||||
|
</div>
|
||||||
|
) : paymentMethods.length === 0 ? (
|
||||||
|
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-800 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-200">
|
||||||
|
<p className="text-sm">No payment methods available. Please contact support.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{paymentMethods.map((method) => (
|
||||||
|
<div
|
||||||
|
key={method.id}
|
||||||
|
onClick={() => setSelectedPaymentMethod(method.payment_method)}
|
||||||
|
className={`relative p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||||
|
selectedPaymentMethod === method.payment_method
|
||||||
|
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center w-10 h-10 rounded-lg ${
|
||||||
|
selectedPaymentMethod === method.payment_method ? 'bg-brand-500 text-white' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getPaymentIcon(method.payment_method)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-white">{method.display_name}</h4>
|
||||||
|
{selectedPaymentMethod === method.payment_method && <Check className="w-5 h-5 text-brand-500" />}
|
||||||
|
</div>
|
||||||
|
{method.instructions && <p className="text-xs text-gray-500 dark:text-gray-400 mt-1 whitespace-pre-line">{method.instructions}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 pt-2">
|
||||||
|
<Checkbox className="w-5 h-5 mt-0.5" checked={isChecked} onChange={setIsChecked} />
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
By creating an account means you agree to the <span className="text-gray-800 dark:text-white/90">Terms and Conditions</span>, and our{' '}
|
||||||
|
<span className="text-gray-800 dark:text-white">Privacy Policy</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary" disabled={loading} className="w-full">
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Creating your account...
|
||||||
|
</span>
|
||||||
|
) : isPaidPlan ? (
|
||||||
|
'Create Account & Continue to Payment'
|
||||||
|
) : (
|
||||||
|
'Start Free Trial'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-5 text-center">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/signin" className="text-brand-500 hover:text-brand-600 dark:text-brand-400 font-medium">
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Pricing Panel - Renders in right side */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
{/* This will be portaled to the right side */}
|
||||||
|
{typeof document !== 'undefined' &&
|
||||||
|
document.getElementById('signup-pricing-plans') &&
|
||||||
|
ReactDOM.createPortal(
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Billing Toggle - Improved UI */}
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="relative inline-flex p-1 bg-gray-100 dark:bg-gray-800 rounded-xl shadow-sm">
|
||||||
|
<span
|
||||||
|
className={`absolute top-1/2 left-1 flex h-11 w-32 -translate-y-1/2 rounded-lg bg-gradient-to-br from-brand-500 to-brand-600 shadow-md transition-all duration-300 ease-out ${
|
||||||
|
billingPeriod === 'monthly' ? 'translate-x-0' : 'translate-x-32'
|
||||||
|
}`}
|
||||||
|
></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBillingPeriod('monthly')}
|
||||||
|
className={`relative flex h-11 w-32 items-center justify-center text-base font-semibold transition-all duration-200 rounded-lg ${
|
||||||
|
billingPeriod === 'monthly' ? 'text-white' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBillingPeriod('annually')}
|
||||||
|
className={`relative flex h-11 w-32 items-center justify-center text-base font-semibold transition-all duration-200 rounded-lg ${
|
||||||
|
billingPeriod === 'annually' ? 'text-white' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Annually
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-7 flex items-center justify-center">
|
||||||
|
<p className={`inline-flex items-center gap-1.5 text-green-600 dark:text-green-400 text-sm font-semibold bg-green-50 dark:bg-green-900/20 px-3 py-1.5 rounded-full transition-opacity duration-200 ${
|
||||||
|
billingPeriod === 'annually' ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Save 15% with annual billing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan Cards - 2 columns, wider cards, no buttons */}
|
||||||
|
<div className="grid gap-5 grid-cols-1 xl:grid-cols-2">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const displayPrice = getDisplayPrice(plan);
|
||||||
|
const features = extractFeatures(plan);
|
||||||
|
const isSelected = selectedPlan?.id === plan.id;
|
||||||
|
const isFree = parseFloat(String(plan.price || 0)) === 0;
|
||||||
|
const isPopular = plan.slug.toLowerCase().includes('growth');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={plan.id}
|
||||||
|
onClick={() => onPlanSelect(plan)}
|
||||||
|
className={`relative rounded-2xl p-6 cursor-pointer transition-all duration-300 border-2 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-brand-500 bg-white dark:bg-gray-800 shadow-2xl ring-4 ring-brand-500/20'
|
||||||
|
: isPopular
|
||||||
|
? 'border-brand-200 dark:border-brand-800 bg-white dark:bg-gray-800/50 hover:shadow-xl'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/50 hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPopular && !isSelected && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs font-bold px-3 py-1 rounded-full shadow-lg">
|
||||||
|
⭐ POPULAR
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute -top-4 -right-4">
|
||||||
|
<div className="bg-brand-500 rounded-full p-2 shadow-lg">
|
||||||
|
<CheckCircle className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-5">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">{plan.name}</h3>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{isFree ? 'Free' : `$${displayPrice.toFixed(2)}`}
|
||||||
|
</span>
|
||||||
|
<div className="h-5 flex items-center">
|
||||||
|
{!isFree && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{billingPeriod === 'annually' ? '/year' : '/month'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-5">
|
||||||
|
{billingPeriod === 'annually' && !isFree && (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-xs">
|
||||||
|
${(displayPrice / 12).toFixed(2)}/month billed annually
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features - 3 rows x 2 columns = 6 features */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5">
|
||||||
|
{features.slice(0, 6).map((feature, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300 leading-tight">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.getElementById('signup-pricing-plans')!
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@ interface PaymentConfirmationModalProps {
|
|||||||
invoice: {
|
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">
|
||||||
|
|||||||
175
frontend/src/components/billing/PaymentHistory.tsx
Normal file
175
frontend/src/components/billing/PaymentHistory.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { API_BASE_URL } from '../../services/api';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
import { CheckCircle, XCircle, Clock, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Payment {
|
||||||
|
id: number;
|
||||||
|
invoice_id: number;
|
||||||
|
invoice_number: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
payment_method: string;
|
||||||
|
created_at: string;
|
||||||
|
processed_at?: string;
|
||||||
|
manual_reference?: string;
|
||||||
|
manual_notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentHistory() {
|
||||||
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const { token } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPayments();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPayments = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/billing/payments/`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setPayments(data.results || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load payment history');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'succeeded':
|
||||||
|
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||||
|
case 'pending_approval':
|
||||||
|
return <Clock className="w-5 h-5 text-yellow-500" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="w-5 h-5 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const badges = {
|
||||||
|
succeeded: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||||
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
|
||||||
|
pending_approval: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
|
||||||
|
refunded: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400',
|
||||||
|
};
|
||||||
|
return badges[status as keyof typeof badges] || badges.pending_approval;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center p-12">
|
||||||
|
<RefreshCw className="w-8 h-8 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Payment History
|
||||||
|
</h2>
|
||||||
|
<Button onClick={loadPayments} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-600 dark:bg-red-900/20 dark:border-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payments.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<p>No payment history yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Invoice
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Amount
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Method
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Reference
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{payments.map((payment) => (
|
||||||
|
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
#{payment.invoice_number}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{payment.currency} {payment.amount}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{payment.payment_method.replace('_', ' ')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(payment.status)}
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusBadge(payment.status)}`}>
|
||||||
|
{payment.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatDate(payment.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{payment.manual_reference || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ import PaymentConfirmationModal from './PaymentConfirmationModal';
|
|||||||
interface Invoice {
|
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'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user