STripe Paymen and PK payemtns and many othe rbacekd and froentened issues
This commit is contained in:
@@ -214,6 +214,7 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
||||
'bulk_add_credits',
|
||||
'bulk_subtract_credits',
|
||||
'bulk_soft_delete',
|
||||
'bulk_hard_delete',
|
||||
]
|
||||
|
||||
def get_queryset(self, request):
|
||||
@@ -454,14 +455,32 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
||||
bulk_subtract_credits.short_description = 'Subtract credits from accounts'
|
||||
|
||||
def bulk_soft_delete(self, request, queryset):
|
||||
"""Soft delete selected accounts"""
|
||||
"""Soft delete selected accounts and all related data"""
|
||||
count = 0
|
||||
for account in queryset:
|
||||
if account.slug != 'aws-admin': # Protect admin account
|
||||
account.delete() # Soft delete via SoftDeletableModel
|
||||
account.delete() # Soft delete via SoftDeletableModel (now cascades)
|
||||
count += 1
|
||||
self.message_user(request, f'{count} account(s) soft deleted.', messages.SUCCESS)
|
||||
bulk_soft_delete.short_description = 'Soft delete selected accounts'
|
||||
self.message_user(request, f'{count} account(s) and all related data soft deleted.', messages.SUCCESS)
|
||||
bulk_soft_delete.short_description = 'Soft delete accounts (with cascade)'
|
||||
|
||||
def bulk_hard_delete(self, request, queryset):
|
||||
"""PERMANENTLY delete selected accounts and ALL related data - cannot be undone!"""
|
||||
count = 0
|
||||
errors = []
|
||||
for account in queryset:
|
||||
if account.slug != 'aws-admin': # Protect admin account
|
||||
try:
|
||||
account.hard_delete_with_cascade() # Permanently delete everything
|
||||
count += 1
|
||||
except Exception as e:
|
||||
errors.append(f'{account.name}: {str(e)}')
|
||||
|
||||
if count > 0:
|
||||
self.message_user(request, f'{count} account(s) and ALL related data permanently deleted.', messages.SUCCESS)
|
||||
if errors:
|
||||
self.message_user(request, f'Errors: {"; ".join(errors)}', messages.ERROR)
|
||||
bulk_hard_delete.short_description = '⚠️ PERMANENTLY delete accounts (irreversible!)'
|
||||
|
||||
|
||||
class SubscriptionResource(resources.ModelResource):
|
||||
|
||||
@@ -153,12 +153,144 @@ class Account(SoftDeletableModel):
|
||||
# System accounts bypass all filtering restrictions
|
||||
return self.slug in ['aws-admin', 'default-account', 'default']
|
||||
|
||||
def soft_delete(self, user=None, reason=None, retention_days=None):
|
||||
def soft_delete(self, user=None, reason=None, retention_days=None, cascade=True):
|
||||
"""
|
||||
Soft delete the account and optionally cascade to all related objects.
|
||||
Args:
|
||||
user: User performing the deletion
|
||||
reason: Reason for deletion
|
||||
retention_days: Days before permanent deletion
|
||||
cascade: If True, also soft-delete related objects that support soft delete,
|
||||
and hard-delete objects that don't support soft delete
|
||||
"""
|
||||
if self.is_system_account():
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied("System account cannot be deleted.")
|
||||
|
||||
if cascade:
|
||||
self._cascade_delete_related(user=user, reason=reason, retention_days=retention_days, hard_delete=False)
|
||||
|
||||
return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
|
||||
def _cascade_delete_related(self, user=None, reason=None, retention_days=None, hard_delete=False):
|
||||
"""
|
||||
Delete all related objects when account is deleted.
|
||||
For soft delete: soft-deletes objects with SoftDeletableModel, hard-deletes others
|
||||
For hard delete: hard-deletes everything
|
||||
"""
|
||||
from igny8_core.common.soft_delete import SoftDeletableModel
|
||||
|
||||
# List of related objects to delete (in order to avoid FK constraint issues)
|
||||
# Related names from Account reverse relations
|
||||
related_names = [
|
||||
# Content & Planning related (delete first due to dependencies)
|
||||
'contentclustermap_set',
|
||||
'contentattribute_set',
|
||||
'contenttaxonomy_set',
|
||||
'content_set',
|
||||
'images_set',
|
||||
'contentideas_set',
|
||||
'tasks_set',
|
||||
'keywords_set',
|
||||
'clusters_set',
|
||||
'strategy_set',
|
||||
# Automation
|
||||
'automation_runs',
|
||||
'automation_configs',
|
||||
# Publishing & Integration
|
||||
'syncevent_set',
|
||||
'publishingsettings_set',
|
||||
'publishingrecord_set',
|
||||
'deploymentrecord_set',
|
||||
'siteintegration_set',
|
||||
# Notifications & Optimization
|
||||
'notification_set',
|
||||
'optimizationtask_set',
|
||||
# AI & Settings
|
||||
'aitasklog_set',
|
||||
'aiprompt_set',
|
||||
'aisettings_set',
|
||||
'authorprofile_set',
|
||||
# Billing (preserve invoices/payments for audit, delete others)
|
||||
'planlimitusage_set',
|
||||
'creditusagelog_set',
|
||||
'credittransaction_set',
|
||||
'accountpaymentmethod_set',
|
||||
'payment_set',
|
||||
'invoice_set',
|
||||
# Settings
|
||||
'modulesettings_set',
|
||||
'moduleenablesettings_set',
|
||||
'integrationsettings_set',
|
||||
'user_settings',
|
||||
'accountsettings_set',
|
||||
# Core (last due to dependencies)
|
||||
'sector_set',
|
||||
'site_set',
|
||||
# Subscription (OneToOne)
|
||||
'subscription',
|
||||
]
|
||||
|
||||
for related_name in related_names:
|
||||
try:
|
||||
related = getattr(self, related_name, None)
|
||||
if related is None:
|
||||
continue
|
||||
|
||||
# Handle OneToOne fields (subscription)
|
||||
if hasattr(related, 'pk'):
|
||||
# It's a single object (OneToOneField)
|
||||
if hard_delete:
|
||||
related.hard_delete() if hasattr(related, 'hard_delete') else related.delete()
|
||||
elif isinstance(related, SoftDeletableModel):
|
||||
related.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
else:
|
||||
# Non-soft-deletable single object - hard delete
|
||||
related.delete()
|
||||
else:
|
||||
# It's a RelatedManager (ForeignKey)
|
||||
queryset = related.all()
|
||||
if queryset.exists():
|
||||
if hard_delete:
|
||||
# Hard delete all
|
||||
if hasattr(queryset, 'hard_delete'):
|
||||
queryset.hard_delete()
|
||||
else:
|
||||
for obj in queryset:
|
||||
if hasattr(obj, 'hard_delete'):
|
||||
obj.hard_delete()
|
||||
else:
|
||||
obj.delete()
|
||||
else:
|
||||
# Soft delete if supported, otherwise hard delete
|
||||
model = queryset.model
|
||||
if issubclass(model, SoftDeletableModel):
|
||||
for obj in queryset:
|
||||
obj.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
else:
|
||||
queryset.delete()
|
||||
except Exception as e:
|
||||
# Log but don't fail - some relations may not exist
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Failed to delete related {related_name} for account {self.pk}: {e}")
|
||||
|
||||
def hard_delete_with_cascade(self, using=None, keep_parents=False):
|
||||
"""
|
||||
Permanently delete the account and ALL related objects.
|
||||
This bypasses soft-delete and removes everything from the database.
|
||||
USE WITH CAUTION - this cannot be undone!
|
||||
"""
|
||||
if self.is_system_account():
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied("System account cannot be deleted.")
|
||||
|
||||
# Cascade hard-delete all related objects first
|
||||
self._cascade_delete_related(hard_delete=True)
|
||||
|
||||
# Finally hard-delete the account itself
|
||||
return super().hard_delete(using=using, keep_parents=keep_parents)
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
return self.soft_delete()
|
||||
|
||||
|
||||
@@ -406,11 +406,20 @@ class RegisterSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
# Generate unique slug for account
|
||||
base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account'
|
||||
slug = base_slug
|
||||
# Clean the base slug: lowercase, replace spaces and underscores with hyphens
|
||||
import re
|
||||
import random
|
||||
import string
|
||||
base_slug = re.sub(r'[^a-z0-9-]', '', account_name.lower().replace(' ', '-').replace('_', '-'))[:40] or 'account'
|
||||
|
||||
# Add random suffix to prevent collisions (especially during concurrent registrations)
|
||||
random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
slug = f"{base_slug}-{random_suffix}"
|
||||
|
||||
# Ensure uniqueness with fallback counter
|
||||
counter = 1
|
||||
while Account.objects.filter(slug=slug).exists():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
slug = f"{base_slug}-{random_suffix}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create account with status and credits seeded (0 for paid pending)
|
||||
|
||||
@@ -109,16 +109,70 @@ class RegisterView(APIView):
|
||||
refresh_expires_at = timezone.now() + get_refresh_token_expiry()
|
||||
|
||||
user_serializer = UserSerializer(user)
|
||||
|
||||
# Build response data
|
||||
response_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(),
|
||||
}
|
||||
}
|
||||
|
||||
# For Stripe payment method with paid plan, create checkout session
|
||||
payment_method = request.data.get('payment_method', '')
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Registration: payment_method={payment_method}, account_status={account.status if account else 'no account'}")
|
||||
|
||||
if account and account.status == 'pending_payment' and payment_method == 'stripe':
|
||||
try:
|
||||
from igny8_core.business.billing.services.stripe_service import StripeService
|
||||
stripe_service = StripeService()
|
||||
logger.info(f"Creating Stripe checkout for account {account.id}, plan {account.plan.name}")
|
||||
checkout_data = stripe_service.create_checkout_session(
|
||||
account=account,
|
||||
plan=account.plan,
|
||||
)
|
||||
logger.info(f"Stripe checkout created: {checkout_data}")
|
||||
response_data['checkout_url'] = checkout_data.get('checkout_url')
|
||||
response_data['checkout_session_id'] = checkout_data.get('session_id')
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Stripe checkout session: {e}", exc_info=True)
|
||||
# Don't fail registration, just log the error
|
||||
# User can still complete payment from the plans page
|
||||
|
||||
# For PayPal payment method with paid plan, create PayPal order
|
||||
elif account and account.status == 'pending_payment' and payment_method == 'paypal':
|
||||
try:
|
||||
from django.conf import settings
|
||||
from igny8_core.business.billing.services.paypal_service import PayPalService
|
||||
paypal_service = PayPalService()
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
|
||||
|
||||
logger.info(f"Creating PayPal order for account {account.id}, amount {account.plan.price}")
|
||||
order = paypal_service.create_order(
|
||||
account=account,
|
||||
amount=float(account.plan.price),
|
||||
description=f'{account.plan.name} Plan Subscription',
|
||||
return_url=f'{frontend_url}/account/plans?paypal=success&plan_id={account.plan.id}',
|
||||
cancel_url=f'{frontend_url}/account/plans?paypal=cancel',
|
||||
metadata={
|
||||
'plan_id': str(account.plan.id),
|
||||
'type': 'subscription',
|
||||
}
|
||||
)
|
||||
logger.info(f"PayPal order created: {order}")
|
||||
response_data['checkout_url'] = order.get('approval_url')
|
||||
response_data['paypal_order_id'] = order.get('order_id')
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create PayPal order: {e}", exc_info=True)
|
||||
# Don't fail registration, just log the error
|
||||
|
||||
return success_response(
|
||||
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(),
|
||||
}
|
||||
},
|
||||
data=response_data,
|
||||
message='Registration successful',
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
request=request
|
||||
|
||||
@@ -578,8 +578,10 @@ class CreditPackage(models.Model):
|
||||
|
||||
class PaymentMethodConfig(models.Model):
|
||||
"""
|
||||
Configure payment methods availability per country
|
||||
Allows enabling/disabling manual payments by region
|
||||
Configure payment methods availability per country.
|
||||
|
||||
For online payments (stripe, paypal): Credentials stored in IntegrationProvider.
|
||||
For manual payments (bank_transfer, local_wallet): Bank/wallet details stored here.
|
||||
"""
|
||||
# Use centralized choices
|
||||
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
||||
@@ -587,7 +589,7 @@ class PaymentMethodConfig(models.Model):
|
||||
country_code = models.CharField(
|
||||
max_length=2,
|
||||
db_index=True,
|
||||
help_text="ISO 2-letter country code (e.g., US, GB, IN)"
|
||||
help_text="ISO 2-letter country code (e.g., US, GB, PK) or '*' for global"
|
||||
)
|
||||
payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES)
|
||||
is_enabled = models.BooleanField(default=True)
|
||||
@@ -596,21 +598,17 @@ class PaymentMethodConfig(models.Model):
|
||||
display_name = models.CharField(max_length=100, blank=True)
|
||||
instructions = models.TextField(blank=True, help_text="Payment instructions for users")
|
||||
|
||||
# Manual payment details (for bank_transfer/local_wallet)
|
||||
# Manual payment details (for bank_transfer only)
|
||||
bank_name = models.CharField(max_length=255, blank=True)
|
||||
account_number = models.CharField(max_length=255, blank=True)
|
||||
routing_number = models.CharField(max_length=255, blank=True)
|
||||
swift_code = models.CharField(max_length=255, blank=True)
|
||||
account_title = models.CharField(max_length=255, blank=True, help_text="Account holder name")
|
||||
routing_number = models.CharField(max_length=255, blank=True, help_text="Routing/Sort code")
|
||||
swift_code = models.CharField(max_length=255, blank=True, help_text="SWIFT/BIC code for international")
|
||||
iban = models.CharField(max_length=255, blank=True, help_text="IBAN for international transfers")
|
||||
|
||||
# Additional fields for local wallets
|
||||
wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., PayTM, PhonePe, etc.")
|
||||
wallet_id = models.CharField(max_length=255, blank=True)
|
||||
|
||||
# Webhook configuration (Stripe/PayPal)
|
||||
webhook_url = models.URLField(blank=True, help_text="Webhook URL for payment gateway callbacks")
|
||||
webhook_secret = models.CharField(max_length=255, blank=True, help_text="Webhook secret for signature verification")
|
||||
api_key = models.CharField(max_length=255, blank=True, help_text="API key for payment gateway integration")
|
||||
api_secret = models.CharField(max_length=255, blank=True, help_text="API secret for payment gateway integration")
|
||||
wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., JazzCash, EasyPaisa, etc.")
|
||||
wallet_id = models.CharField(max_length=255, blank=True, help_text="Mobile number or wallet ID")
|
||||
|
||||
# Order/priority
|
||||
sort_order = models.IntegerField(default=0)
|
||||
|
||||
@@ -14,6 +14,40 @@ from ....auth.models import Account, Subscription
|
||||
class InvoiceService:
|
||||
"""Service for managing invoices"""
|
||||
|
||||
@staticmethod
|
||||
def get_pending_invoice(subscription: Subscription) -> Optional[Invoice]:
|
||||
"""
|
||||
Get pending invoice for a subscription.
|
||||
Used to find existing invoice during payment processing instead of creating duplicates.
|
||||
"""
|
||||
return Invoice.objects.filter(
|
||||
subscription=subscription,
|
||||
status='pending'
|
||||
).order_by('-created_at').first()
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_subscription_invoice(
|
||||
subscription: Subscription,
|
||||
billing_period_start: datetime,
|
||||
billing_period_end: datetime
|
||||
) -> tuple[Invoice, bool]:
|
||||
"""
|
||||
Get existing pending invoice or create new one.
|
||||
Returns tuple of (invoice, created) where created is True if new invoice was created.
|
||||
"""
|
||||
# First try to find existing pending invoice for this subscription
|
||||
existing = InvoiceService.get_pending_invoice(subscription)
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
# Create new invoice if none exists
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
subscription=subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end
|
||||
)
|
||||
return invoice, True
|
||||
|
||||
@staticmethod
|
||||
def generate_invoice_number(account: Account) -> str:
|
||||
"""
|
||||
@@ -52,6 +86,10 @@ class InvoiceService:
|
||||
) -> Invoice:
|
||||
"""
|
||||
Create invoice for subscription billing period
|
||||
|
||||
Currency logic:
|
||||
- USD for online payments (stripe, paypal)
|
||||
- Local currency (PKR) only for bank_transfer in applicable countries
|
||||
"""
|
||||
account = subscription.account
|
||||
plan = subscription.plan
|
||||
@@ -74,12 +112,22 @@ class InvoiceService:
|
||||
invoice_date = timezone.now().date()
|
||||
due_date = invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET)
|
||||
|
||||
# Get currency based on billing country
|
||||
# Determine currency based on payment method:
|
||||
# - Online payments (stripe, paypal): Always USD
|
||||
# - Manual payments (bank_transfer, local_wallet): Local currency for applicable countries
|
||||
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)
|
||||
payment_method = account.payment_method
|
||||
online_payment_methods = ['stripe', 'paypal']
|
||||
|
||||
if payment_method in online_payment_methods:
|
||||
# Online payments are always in USD
|
||||
currency = 'USD'
|
||||
local_price = float(plan.price)
|
||||
else:
|
||||
# Manual payments use local currency for applicable countries
|
||||
currency = get_currency_for_country(account.billing_country)
|
||||
local_price = convert_usd_to_local(float(plan.price), account.billing_country)
|
||||
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
@@ -95,7 +143,8 @@ class InvoiceService:
|
||||
'billing_period_end': billing_period_end.isoformat(),
|
||||
'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)
|
||||
'exchange_rate': str(local_price / float(plan.price) if plan.price > 0 else 1.0),
|
||||
'payment_method': payment_method
|
||||
}
|
||||
)
|
||||
|
||||
@@ -120,16 +169,28 @@ class InvoiceService:
|
||||
) -> Invoice:
|
||||
"""
|
||||
Create invoice for credit package purchase
|
||||
|
||||
Currency logic:
|
||||
- USD for online payments (stripe, paypal)
|
||||
- Local currency (PKR) only for bank_transfer in applicable countries
|
||||
"""
|
||||
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
|
||||
invoice_date = timezone.now().date()
|
||||
|
||||
# Get currency based on billing country
|
||||
# Determine currency based on payment method
|
||||
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)
|
||||
payment_method = account.payment_method
|
||||
online_payment_methods = ['stripe', 'paypal']
|
||||
|
||||
if payment_method in online_payment_methods:
|
||||
# Online payments are always in USD
|
||||
currency = 'USD'
|
||||
local_price = float(credit_package.price)
|
||||
else:
|
||||
# Manual payments use local currency for applicable countries
|
||||
currency = get_currency_for_country(account.billing_country)
|
||||
local_price = convert_usd_to_local(float(credit_package.price), account.billing_country)
|
||||
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
@@ -143,7 +204,8 @@ class InvoiceService:
|
||||
'credit_package_id': credit_package.id,
|
||||
'credit_amount': credit_package.credits,
|
||||
'usd_price': str(credit_package.price), # Store original USD price
|
||||
'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0)
|
||||
'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0),
|
||||
'payment_method': payment_method
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -627,10 +627,9 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) -
|
||||
}
|
||||
)
|
||||
|
||||
# Create invoice
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
account=account,
|
||||
plan=plan,
|
||||
# Get existing pending invoice or create new one (avoids duplicates)
|
||||
invoice, invoice_created = InvoiceService.get_or_create_subscription_invoice(
|
||||
subscription=subscription,
|
||||
billing_period_start=now,
|
||||
billing_period_end=period_end,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ Endpoints:
|
||||
"""
|
||||
import stripe
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone as dt_timezone, timedelta
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@@ -402,6 +402,7 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
|
||||
# Get subscription details from Stripe
|
||||
try:
|
||||
stripe_sub = stripe.Subscription.retrieve(stripe_subscription_id)
|
||||
logger.info(f"Retrieved Stripe subscription: {stripe_subscription_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve Stripe subscription {stripe_subscription_id}: {e}")
|
||||
return
|
||||
@@ -413,6 +414,40 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
|
||||
logger.error(f"Plan {plan_id} not found for subscription activation")
|
||||
return
|
||||
|
||||
# Extract period dates from Stripe subscription
|
||||
# Note: In newer Stripe API, current_period_start/end are on subscription items, not the subscription
|
||||
try:
|
||||
# Convert to dict for easier access
|
||||
sub_dict = stripe_sub.to_dict_recursive() if hasattr(stripe_sub, 'to_dict_recursive') else dict(stripe_sub)
|
||||
|
||||
# Try to get period from subscription items first (new API)
|
||||
items = sub_dict.get('items', {}).get('data', [])
|
||||
if items:
|
||||
first_item = items[0]
|
||||
period_start = first_item.get('current_period_start')
|
||||
period_end = first_item.get('current_period_end')
|
||||
else:
|
||||
# Fallback to subscription level (old API)
|
||||
period_start = sub_dict.get('current_period_start')
|
||||
period_end = sub_dict.get('current_period_end')
|
||||
|
||||
# If still not found, use billing_cycle_anchor or start_date
|
||||
if not period_start:
|
||||
period_start = sub_dict.get('billing_cycle_anchor') or sub_dict.get('start_date') or sub_dict.get('created')
|
||||
if not period_end:
|
||||
# Default to 30 days from start
|
||||
period_end = period_start + (30 * 24 * 60 * 60) if period_start else None
|
||||
|
||||
cancel_at_end = sub_dict.get('cancel_at_period_end', False)
|
||||
|
||||
logger.info(f"Extracted period: start={period_start}, end={period_end}, cancel_at_end={cancel_at_end}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract period dates from Stripe subscription: {e}")
|
||||
# Use current time as fallback
|
||||
period_start = timezone.now().timestamp()
|
||||
period_end = (timezone.now() + timedelta(days=30)).timestamp()
|
||||
cancel_at_end = False
|
||||
|
||||
with transaction.atomic():
|
||||
# Create or update subscription
|
||||
subscription, created = Subscription.objects.update_or_create(
|
||||
@@ -422,27 +457,29 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
|
||||
'stripe_subscription_id': stripe_subscription_id,
|
||||
'status': 'active',
|
||||
'current_period_start': datetime.fromtimestamp(
|
||||
stripe_sub.current_period_start,
|
||||
tz=timezone.utc
|
||||
period_start,
|
||||
tz=dt_timezone.utc
|
||||
),
|
||||
'current_period_end': datetime.fromtimestamp(
|
||||
stripe_sub.current_period_end,
|
||||
tz=timezone.utc
|
||||
period_end,
|
||||
tz=dt_timezone.utc
|
||||
),
|
||||
'cancel_at_period_end': stripe_sub.cancel_at_period_end,
|
||||
'cancel_at_period_end': cancel_at_end,
|
||||
}
|
||||
)
|
||||
|
||||
# Create invoice record
|
||||
# Get existing pending invoice or create new one (avoids duplicates)
|
||||
amount = session.get('amount_total', 0) / 100 # Convert from cents
|
||||
currency = session.get('currency', 'usd').upper()
|
||||
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
account=account,
|
||||
plan=plan,
|
||||
invoice, invoice_created = InvoiceService.get_or_create_subscription_invoice(
|
||||
subscription=subscription,
|
||||
billing_period_start=subscription.current_period_start,
|
||||
billing_period_end=subscription.current_period_end,
|
||||
)
|
||||
|
||||
if not invoice_created:
|
||||
logger.info(f"Found existing pending invoice {invoice.invoice_number} for subscription")
|
||||
|
||||
# Mark invoice as paid
|
||||
InvoiceService.mark_paid(
|
||||
@@ -481,10 +518,15 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
|
||||
}
|
||||
)
|
||||
|
||||
# Update account status if needed
|
||||
# Update account status and plan
|
||||
update_fields = ['updated_at']
|
||||
if account.status != 'active':
|
||||
account.status = 'active'
|
||||
account.save(update_fields=['status', 'updated_at'])
|
||||
update_fields.append('status')
|
||||
if account.plan_id != plan.id:
|
||||
account.plan = plan
|
||||
update_fields.append('plan')
|
||||
account.save(update_fields=update_fields)
|
||||
|
||||
logger.info(
|
||||
f"Subscription activated for account {account.id}: "
|
||||
@@ -659,15 +701,33 @@ def _handle_subscription_updated(subscription_data: dict):
|
||||
stripe_status = subscription_data.get('status')
|
||||
new_status = status_map.get(stripe_status, 'active')
|
||||
|
||||
# Update period dates
|
||||
subscription.current_period_start = datetime.fromtimestamp(
|
||||
subscription_data.get('current_period_start'),
|
||||
tz=timezone.utc
|
||||
)
|
||||
subscription.current_period_end = datetime.fromtimestamp(
|
||||
subscription_data.get('current_period_end'),
|
||||
tz=timezone.utc
|
||||
)
|
||||
# Extract period dates - check subscription items first (new API), then subscription level
|
||||
items = subscription_data.get('items', {}).get('data', [])
|
||||
if items:
|
||||
first_item = items[0]
|
||||
period_start = first_item.get('current_period_start')
|
||||
period_end = first_item.get('current_period_end')
|
||||
else:
|
||||
period_start = subscription_data.get('current_period_start')
|
||||
period_end = subscription_data.get('current_period_end')
|
||||
|
||||
# Fallback to billing_cycle_anchor if period dates not found
|
||||
if not period_start:
|
||||
period_start = subscription_data.get('billing_cycle_anchor') or subscription_data.get('start_date')
|
||||
if not period_end and period_start:
|
||||
period_end = period_start + (30 * 24 * 60 * 60) # Default 30 days
|
||||
|
||||
# Only update period dates if we have valid values
|
||||
if period_start:
|
||||
subscription.current_period_start = datetime.fromtimestamp(
|
||||
period_start,
|
||||
tz=dt_timezone.utc
|
||||
)
|
||||
if period_end:
|
||||
subscription.current_period_end = datetime.fromtimestamp(
|
||||
period_end,
|
||||
tz=dt_timezone.utc
|
||||
)
|
||||
subscription.cancel_at_period_end = subscription_data.get('cancel_at_period_end', False)
|
||||
subscription.status = new_status
|
||||
subscription.save()
|
||||
|
||||
@@ -519,6 +519,30 @@ class PaymentMethodConfigAdmin(Igny8ModelAdmin):
|
||||
search_fields = ['country_code', 'display_name', 'payment_method']
|
||||
list_editable = ['is_enabled', 'sort_order']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Payment Method', {
|
||||
'fields': ('country_code', 'payment_method', 'display_name', 'is_enabled', 'sort_order')
|
||||
}),
|
||||
('Instructions', {
|
||||
'fields': ('instructions',),
|
||||
'description': 'Instructions shown to users for this payment method'
|
||||
}),
|
||||
('Bank Transfer Details', {
|
||||
'fields': ('bank_name', 'account_title', 'account_number', 'routing_number', 'swift_code', 'iban'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Only for bank_transfer payment method'
|
||||
}),
|
||||
('Local Wallet Details', {
|
||||
'fields': ('wallet_type', 'wallet_id'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Only for local_wallet payment method (JazzCash, EasyPaisa, etc.)'
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(AccountPaymentMethod)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-07 03:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0027_model_schema_update'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='api_key',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='api_secret',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='webhook_secret',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='webhook_url',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='account_title',
|
||||
field=models.CharField(blank=True, help_text='Account holder name', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='iban',
|
||||
field=models.CharField(blank=True, help_text='IBAN for international transfers', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='country_code',
|
||||
field=models.CharField(db_index=True, help_text="ISO 2-letter country code (e.g., US, GB, PK) or '*' for global", max_length=2),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='routing_number',
|
||||
field=models.CharField(blank=True, help_text='Routing/Sort code', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='swift_code',
|
||||
field=models.CharField(blank=True, help_text='SWIFT/BIC code for international', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='wallet_id',
|
||||
field=models.CharField(blank=True, help_text='Mobile number or wallet ID', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='wallet_type',
|
||||
field=models.CharField(blank=True, help_text='E.g., JazzCash, EasyPaisa, etc.', max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -846,3 +846,6 @@ STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '')
|
||||
PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '')
|
||||
PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '')
|
||||
PAYPAL_API_BASE = os.getenv('PAYPAL_API_BASE', 'https://api-m.sandbox.paypal.com')
|
||||
|
||||
# Frontend URL for redirects (Stripe/PayPal success/cancel URLs)
|
||||
FRONTEND_URL = os.getenv('FRONTEND_URL', 'https://app.igny8.com')
|
||||
|
||||
@@ -26,6 +26,7 @@ from igny8_core.auth.views import (
|
||||
industrysector_csv_template, industrysector_csv_import,
|
||||
seedkeyword_csv_template, seedkeyword_csv_import
|
||||
)
|
||||
from igny8_core.utils.geo_views import GeoDetectView
|
||||
|
||||
urlpatterns = [
|
||||
# CSV Import/Export for admin - MUST come before admin/ to avoid being caught by admin.site.urls
|
||||
@@ -49,6 +50,7 @@ urlpatterns = [
|
||||
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
||||
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
||||
path('api/v1/integration/', include('igny8_core.modules.integration.urls')), # Integration endpoints
|
||||
path('api/v1/geo/detect/', GeoDetectView.as_view(), name='geo-detect'), # Geo detection for signup routing
|
||||
# OpenAPI Schema and Documentation
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
|
||||
42
backend/igny8_core/utils/geo_views.py
Normal file
42
backend/igny8_core/utils/geo_views.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Utility views for miscellaneous API endpoints
|
||||
"""
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class GeoDetectView(APIView):
|
||||
"""
|
||||
Detect user's country from request headers.
|
||||
|
||||
Uses Cloudflare's CF-IPCountry header if behind Cloudflare,
|
||||
otherwise falls back to checking X-Country-Code or other headers.
|
||||
|
||||
This endpoint is used by the frontend to determine which signup page
|
||||
to show (/signup for global, /signup/pk for Pakistan).
|
||||
"""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
# Try Cloudflare header first (most reliable when behind CF)
|
||||
country_code = request.META.get('HTTP_CF_IPCOUNTRY', '')
|
||||
|
||||
# Fallback to X-Country-Code header (can be set by load balancer/CDN)
|
||||
if not country_code:
|
||||
country_code = request.META.get('HTTP_X_COUNTRY_CODE', '')
|
||||
|
||||
# Fallback to GeoIP2 if available (Django GeoIP2 middleware)
|
||||
if not country_code:
|
||||
country_code = getattr(request, 'country_code', '')
|
||||
|
||||
# Default to empty string (frontend will treat as global)
|
||||
country_code = country_code.upper() if country_code else ''
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': {
|
||||
'country_code': country_code,
|
||||
'detected': bool(country_code),
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user