diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py
index d66292fb..acf1bc49 100644
--- a/backend/igny8_core/auth/admin.py
+++ b/backend/igny8_core/auth/admin.py
@@ -893,31 +893,6 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
}),
)
- def has_delete_permission(self, request, obj=None):
- """Allow deletion for superusers and developers"""
- return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer())
-
- def delete_model(self, request, obj):
- """Override delete to handle PROTECT relationship with Keywords"""
- from igny8_core.business.planning.models import Keywords
- # Soft-delete all Keywords referencing this SeedKeyword first
- site_keywords = Keywords.objects.filter(seed_keyword=obj)
- for kw in site_keywords:
- kw.soft_delete(user=request.user, reason=f"Parent seed keyword '{obj.keyword}' deleted")
- # Now we can safely delete the SeedKeyword
- super().delete_model(request, obj)
-
- def delete_queryset(self, request, queryset):
- """Override bulk delete to handle PROTECT relationship with Keywords"""
- from igny8_core.business.planning.models import Keywords
- for seed_keyword in queryset:
- # Soft-delete all Keywords referencing this SeedKeyword first
- site_keywords = Keywords.objects.filter(seed_keyword=seed_keyword)
- for kw in site_keywords:
- kw.soft_delete(user=request.user, reason=f"Parent seed keyword '{seed_keyword.keyword}' deleted")
- # Now we can safely delete the SeedKeywords
- queryset.delete()
-
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS)
diff --git a/backend/igny8_core/business/automation/services/automation_service.py b/backend/igny8_core/business/automation/services/automation_service.py
index 5f3a87f8..ab79309d 100644
--- a/backend/igny8_core/business/automation/services/automation_service.py
+++ b/backend/igny8_core/business/automation/services/automation_service.py
@@ -1896,18 +1896,27 @@ class AutomationService:
"""
Get total credits used by this run so far.
Uses CreditUsageLog (same source as /account/usage/credits endpoint) for accuracy.
+ Filters by site to only count credits used by this specific automation run.
"""
if not self.run:
return 0
- # FIXED: Use CreditUsageLog instead of counting AITaskLog records
- # This matches the source of truth used by /account/usage/credits endpoint
+ # Use CreditUsageLog - the source of truth for credit usage
+ # Filter by site to get only credits used by this automation run
from igny8_core.business.billing.models import CreditUsageLog
from django.db.models import Sum
+ filters = {
+ 'account': self.account,
+ 'created_at__gte': self.run.started_at
+ }
+
+ # Filter by site if available for more accurate per-run counting
+ if self.site:
+ filters['site'] = self.site
+
total = CreditUsageLog.objects.filter(
- account=self.account,
- created_at__gte=self.run.started_at
+ **filters
).aggregate(total=Sum('credits_used'))['total'] or 0
return total
diff --git a/backend/igny8_core/business/billing/services/email_service.py b/backend/igny8_core/business/billing/services/email_service.py
index e299b49a..f3e9e001 100644
--- a/backend/igny8_core/business/billing/services/email_service.py
+++ b/backend/igny8_core/business/billing/services/email_service.py
@@ -1055,6 +1055,125 @@ Current Balance: {current_credits} credits
To avoid service interruption, please top up your credits:
{context['topup_url']}
+Thank you,
+The IGNY8 Team
+ """.strip(),
+ )
+
+ @staticmethod
+ def send_invoice_email(invoice, is_reminder=False):
+ """
+ Send invoice email to the account owner.
+ Used for manual payment methods (bank transfer, local wallet, manual).
+
+ Args:
+ invoice: Invoice model instance
+ is_reminder: If True, sends as a reminder email
+ """
+ service = get_email_service()
+ frontend_url = BillingEmailService._get_frontend_url()
+
+ account = invoice.account
+
+ subject_prefix = 'Reminder: ' if is_reminder else ''
+
+ context = {
+ 'account_name': account.name,
+ 'invoice_number': invoice.invoice_number or f'INV-{invoice.id}',
+ 'invoice_date': invoice.created_at.strftime('%Y-%m-%d'),
+ 'due_date': invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A',
+ 'total_amount': invoice.total_amount,
+ 'currency': invoice.currency or 'USD',
+ 'items': invoice.items or [],
+ 'payment_instructions': invoice.metadata.get('payment_instructions', ''),
+ 'is_reminder': is_reminder,
+ 'frontend_url': frontend_url,
+ 'invoice_url': f'{frontend_url}/billing/invoices/{invoice.id}',
+ }
+
+ subject = f'{subject_prefix}Invoice #{context["invoice_number"]} - Payment Required'
+
+ try:
+ result = service.send_transactional(
+ to=account.billing_email or account.owner.email,
+ subject=subject,
+ template='emails/invoice.html',
+ context=context,
+ tags=['billing', 'invoice', 'reminder' if is_reminder else 'new'],
+ )
+ logger.info(f'Invoice email sent for Invoice {invoice.id} (reminder={is_reminder})')
+ return result
+ except Exception as e:
+ logger.error(f'Failed to send invoice email: {str(e)}')
+ reminder_text = 'This is a reminder that your ' if is_reminder else ''
+ return service.send_transactional(
+ to=account.billing_email or account.owner.email,
+ subject=subject,
+ text=f"""
+Hi {account.name},
+
+{reminder_text}Invoice #{context['invoice_number']} requires your attention.
+
+Invoice Details:
+- Invoice Number: {context['invoice_number']}
+- Date: {context['invoice_date']}
+- Due Date: {context['due_date']}
+- Amount: {context['currency']} {context['total_amount']}
+
+To view and pay your invoice:
+{context['invoice_url']}
+
+{context['payment_instructions']}
+
+If you have any questions, please contact our support team.
+
+Thank you,
+The IGNY8 Team
+ """.strip(),
+ )
+
+ @staticmethod
+ def send_subscription_expired_email(account, subscription):
+ """
+ Send email when subscription expires due to non-payment.
+ """
+ service = get_email_service()
+ frontend_url = BillingEmailService._get_frontend_url()
+
+ context = {
+ 'account_name': account.name,
+ 'plan_name': subscription.plan.name if subscription.plan else 'N/A',
+ 'frontend_url': frontend_url,
+ 'billing_url': f'{frontend_url}/account/plans',
+ }
+
+ try:
+ result = service.send_transactional(
+ to=account.billing_email or account.owner.email,
+ subject='Subscription Expired - Action Required',
+ template='emails/subscription_expired.html',
+ context=context,
+ tags=['billing', 'subscription-expired'],
+ )
+ logger.info(f'Subscription expired email sent for account {account.id}')
+ return result
+ except Exception as e:
+ logger.error(f'Failed to send subscription expired email: {str(e)}')
+ return service.send_transactional(
+ to=account.billing_email or account.owner.email,
+ subject='Subscription Expired - Action Required',
+ text=f"""
+Hi {account.name},
+
+Your subscription to the {context['plan_name']} plan has expired due to non-payment.
+
+Your account access may be limited until you renew your subscription.
+
+To reactivate your subscription:
+{context['billing_url']}
+
+If you have any questions or need assistance, please contact our support team.
+
Thank you,
The IGNY8 Team
""".strip(),
diff --git a/backend/igny8_core/business/billing/tasks/subscription_renewal.py b/backend/igny8_core/business/billing/tasks/subscription_renewal.py
index 618295ff..991f3218 100644
--- a/backend/igny8_core/business/billing/tasks/subscription_renewal.py
+++ b/backend/igny8_core/business/billing/tasks/subscription_renewal.py
@@ -14,6 +14,11 @@ import logging
logger = logging.getLogger(__name__)
+# Grace period in days for manual payment before subscription expires
+RENEWAL_GRACE_PERIOD_DAYS = 7
+# Days between invoice reminder emails
+INVOICE_REMINDER_INTERVAL_DAYS = 3
+
@shared_task(name='billing.send_renewal_notices')
def send_renewal_notices():
@@ -99,7 +104,7 @@ def renew_subscription(subscription_id: int):
# Attempt automatic payment if payment method on file
payment_attempted = False
- # Check if account has saved payment method
+ # Check if account has saved payment method for automatic billing
if subscription.metadata.get('stripe_subscription_id'):
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
elif subscription.metadata.get('paypal_subscription_id'):
@@ -110,15 +115,24 @@ def renew_subscription(subscription_id: int):
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
else:
# No automatic payment - send invoice for manual payment
+ # This handles all payment methods: bank_transfer, local_wallet, manual
logger.info(f"Manual payment required for subscription {subscription_id}")
- # Mark subscription as pending renewal
+ # Mark subscription as pending renewal with grace period
+ grace_period_end = timezone.now() + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)
subscription.status = 'pending_renewal'
subscription.metadata['renewal_invoice_id'] = invoice.id
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
+ subscription.metadata['grace_period_end'] = grace_period_end.isoformat()
+ subscription.metadata['last_invoice_reminder_at'] = timezone.now().isoformat()
subscription.save(update_fields=['status', 'metadata'])
- # TODO: Send invoice email
+ # Send invoice email for manual payment
+ try:
+ BillingEmailService.send_invoice_email(invoice, is_reminder=False)
+ logger.info(f"Invoice email sent for subscription {subscription_id}")
+ except Exception as e:
+ logger.exception(f"Failed to send invoice email for subscription {subscription_id}: {str(e)}")
# Clear renewal notice flag
if 'renewal_notice_sent' in subscription.metadata:
@@ -131,6 +145,117 @@ def renew_subscription(subscription_id: int):
logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}")
+@shared_task(name='billing.send_invoice_reminders')
+def send_invoice_reminders():
+ """
+ Send invoice reminder emails for pending renewals
+ Run daily to remind accounts with pending invoices
+ """
+ now = timezone.now()
+ reminder_threshold = now - timedelta(days=INVOICE_REMINDER_INTERVAL_DAYS)
+
+ # Get subscriptions pending renewal
+ subscriptions = Subscription.objects.filter(
+ status='pending_renewal'
+ ).select_related('account', 'plan')
+
+ for subscription in subscriptions:
+ # Check if enough time has passed since last reminder
+ last_reminder = subscription.metadata.get('last_invoice_reminder_at')
+ if last_reminder:
+ from datetime import datetime
+ last_reminder_dt = datetime.fromisoformat(last_reminder.replace('Z', '+00:00'))
+ if hasattr(last_reminder_dt, 'tzinfo') and last_reminder_dt.tzinfo is None:
+ last_reminder_dt = timezone.make_aware(last_reminder_dt)
+ if last_reminder_dt > reminder_threshold:
+ continue
+
+ # Get the renewal invoice
+ invoice_id = subscription.metadata.get('renewal_invoice_id')
+ if not invoice_id:
+ continue
+
+ try:
+ invoice = Invoice.objects.get(id=invoice_id)
+
+ # Only send reminder for unpaid invoices
+ if invoice.status not in ['pending', 'overdue']:
+ continue
+
+ BillingEmailService.send_invoice_email(invoice, is_reminder=True)
+
+ # Update last reminder timestamp
+ subscription.metadata['last_invoice_reminder_at'] = now.isoformat()
+ subscription.save(update_fields=['metadata'])
+
+ logger.info(f"Invoice reminder sent for subscription {subscription.id}")
+
+ except Invoice.DoesNotExist:
+ logger.warning(f"Invoice {invoice_id} not found for subscription {subscription.id}")
+ except Exception as e:
+ logger.exception(f"Failed to send invoice reminder for subscription {subscription.id}: {str(e)}")
+
+
+@shared_task(name='billing.check_expired_renewals')
+def check_expired_renewals():
+ """
+ Check for subscriptions that have exceeded their grace period
+ and automatically change their status to expired
+ Run daily
+ """
+ now = timezone.now()
+
+ # Get subscriptions pending renewal
+ subscriptions = Subscription.objects.filter(
+ status='pending_renewal'
+ ).select_related('account', 'plan')
+
+ expired_count = 0
+
+ for subscription in subscriptions:
+ grace_period_end = subscription.metadata.get('grace_period_end')
+
+ if not grace_period_end:
+ # No grace period set, use default from renewal_required_at
+ renewal_required_at = subscription.metadata.get('renewal_required_at')
+ if renewal_required_at:
+ from datetime import datetime
+ required_dt = datetime.fromisoformat(renewal_required_at.replace('Z', '+00:00'))
+ if hasattr(required_dt, 'tzinfo') and required_dt.tzinfo is None:
+ required_dt = timezone.make_aware(required_dt)
+ grace_period_end = (required_dt + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)).isoformat()
+ else:
+ continue
+
+ # Check if grace period has expired
+ from datetime import datetime
+ grace_end_dt = datetime.fromisoformat(grace_period_end.replace('Z', '+00:00'))
+ if hasattr(grace_end_dt, 'tzinfo') and grace_end_dt.tzinfo is None:
+ grace_end_dt = timezone.make_aware(grace_end_dt)
+
+ if now > grace_end_dt:
+ # Grace period expired - change status to expired
+ subscription.status = 'expired'
+ subscription.metadata['expired_at'] = now.isoformat()
+ subscription.metadata['expired_reason'] = 'Grace period exceeded without payment'
+ subscription.save(update_fields=['status', 'metadata'])
+
+ expired_count += 1
+ logger.info(f"Subscription {subscription.id} expired due to non-payment")
+
+ # Send expiration notification
+ try:
+ BillingEmailService.send_subscription_expired_email(
+ subscription.account,
+ subscription
+ )
+ except Exception as e:
+ logger.exception(f"Failed to send expiration email for subscription {subscription.id}: {str(e)}")
+
+ if expired_count > 0:
+ logger.info(f"Expired {expired_count} subscriptions due to non-payment")
+
+
def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool:
"""
Attempt to charge Stripe subscription
diff --git a/backend/igny8_core/celery.py b/backend/igny8_core/celery.py
index da7cb3b2..51e0bb2e 100644
--- a/backend/igny8_core/celery.py
+++ b/backend/igny8_core/celery.py
@@ -37,6 +37,23 @@ app.conf.beat_schedule = {
'task': 'check_approaching_limits',
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users
},
+ # Subscription Renewal Tasks
+ 'send-renewal-notices': {
+ 'task': 'billing.send_renewal_notices',
+ 'schedule': crontab(hour=9, minute=0), # Daily at 09:00
+ },
+ 'process-subscription-renewals': {
+ 'task': 'billing.process_subscription_renewals',
+ 'schedule': crontab(hour=0, minute=5), # Daily at 00:05
+ },
+ 'send-invoice-reminders': {
+ 'task': 'billing.send_invoice_reminders',
+ 'schedule': crontab(hour=10, minute=0), # Daily at 10:00
+ },
+ 'check-expired-renewals': {
+ 'task': 'billing.check_expired_renewals',
+ 'schedule': crontab(hour=0, minute=15), # Daily at 00:15
+ },
# Automation Tasks
'check-scheduled-automations': {
'task': 'automation.check_scheduled_automations',
diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py
index 82e8548b..c745092b 100644
--- a/backend/igny8_core/modules/billing/views.py
+++ b/backend/igny8_core/modules/billing/views.py
@@ -147,17 +147,26 @@ class CreditUsageViewSet(AccountModelViewSet):
# Default to current month if not provided
now = timezone.now()
+
+ def parse_iso_datetime(dt_str):
+ """Parse ISO datetime string, handling Z suffix for UTC"""
+ if not dt_str:
+ return None
+ # Handle Z suffix (UTC indicator) which Django's parse_datetime doesn't support
+ if dt_str.endswith('Z'):
+ dt_str = dt_str[:-1] + '+00:00'
+ from django.utils.dateparse import parse_datetime
+ return parse_datetime(dt_str)
+
if not start_date:
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
else:
- from django.utils.dateparse import parse_datetime
- start_date = parse_datetime(start_date) or start_date
+ start_date = parse_iso_datetime(start_date) or start_date
if not end_date:
end_date = now
else:
- from django.utils.dateparse import parse_datetime
- end_date = parse_datetime(end_date) or end_date
+ end_date = parse_iso_datetime(end_date) or end_date
# Get usage logs in date range
usage_logs = CreditUsageLog.objects.filter(
diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py
index 818cdd28..bdd46d7c 100644
--- a/backend/igny8_core/modules/writer/views.py
+++ b/backend/igny8_core/modules/writer/views.py
@@ -1114,7 +1114,9 @@ class ContentViewSet(SiteSectorModelViewSet):
request=request
)
- # Parse datetime
+ # Parse datetime - handle Z suffix (UTC indicator) which Django's parse_datetime doesn't support
+ if scheduled_at_str.endswith('Z'):
+ scheduled_at_str = scheduled_at_str[:-1] + '+00:00'
scheduled_at = parse_datetime(scheduled_at_str)
if not scheduled_at:
return error_response(
diff --git a/frontend/src/components/common/SiteCard.tsx b/frontend/src/components/common/SiteCard.tsx
index 3c6311d9..ec21d813 100644
--- a/frontend/src/components/common/SiteCard.tsx
+++ b/frontend/src/components/common/SiteCard.tsx
@@ -90,13 +90,16 @@ export default function SiteCard({
disabled={isToggling}
onChange={handleToggle}
/>
-