Phase 3 - credts, usage, plans app pages #Migrations
This commit is contained in:
@@ -157,6 +157,7 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
from igny8_core.modules.system.models import IntegrationSettings
|
from igny8_core.modules.system.models import IntegrationSettings
|
||||||
from igny8_core.ai.ai_core import AICore
|
from igny8_core.ai.ai_core import AICore
|
||||||
from igny8_core.ai.prompts import PromptRegistry
|
from igny8_core.ai.prompts import PromptRegistry
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
|
||||||
logger.info("=" * 80)
|
logger.info("=" * 80)
|
||||||
logger.info(f"process_image_generation_queue STARTED")
|
logger.info(f"process_image_generation_queue STARTED")
|
||||||
@@ -709,6 +710,33 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
})
|
})
|
||||||
failed += 1
|
failed += 1
|
||||||
else:
|
else:
|
||||||
|
# Deduct credits for successful image generation
|
||||||
|
credits_deducted = 0
|
||||||
|
cost_usd = result.get('cost_usd', 0)
|
||||||
|
if account:
|
||||||
|
try:
|
||||||
|
credits_deducted = CreditService.deduct_credits_for_image(
|
||||||
|
account=account,
|
||||||
|
model_name=model,
|
||||||
|
num_images=1,
|
||||||
|
description=f"Image generation: {content.title[:50] if content else 'Image'}" if content else f"Image {image_id}",
|
||||||
|
metadata={
|
||||||
|
'image_id': image_id,
|
||||||
|
'content_id': content_id,
|
||||||
|
'provider': provider,
|
||||||
|
'model': model,
|
||||||
|
'image_type': image.image_type if image else 'unknown',
|
||||||
|
'size': image_size,
|
||||||
|
},
|
||||||
|
cost_usd=cost_usd,
|
||||||
|
related_object_type='image',
|
||||||
|
related_object_id=image_id
|
||||||
|
)
|
||||||
|
logger.info(f"[process_image_generation_queue] Credits deducted for image {image_id}: account balance now {credits_deducted}")
|
||||||
|
except Exception as credit_error:
|
||||||
|
logger.error(f"[process_image_generation_queue] Failed to deduct credits for image {image_id}: {credit_error}")
|
||||||
|
# Don't fail the image generation if credit deduction fails
|
||||||
|
|
||||||
# Update progress: Complete (100%)
|
# Update progress: Complete (100%)
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state='PROGRESS',
|
state='PROGRESS',
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ class TeamManagementViewSet(viewsets.ViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check hard limit for users BEFORE creating
|
||||||
|
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
|
||||||
|
try:
|
||||||
|
LimitService.check_hard_limit(account, 'users', additional_count=1)
|
||||||
|
except HardLimitExceededError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Create user (simplified - in production, send invitation email)
|
# Create user (simplified - in production, send invitation email)
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
email=email,
|
email=email,
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class PlanResource(resources.ModelResource):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Plan
|
model = Plan
|
||||||
fields = ('id', 'name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users',
|
fields = ('id', 'name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users',
|
||||||
'max_keywords', 'max_content_words', 'included_credits', 'is_active', 'is_featured')
|
'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured')
|
||||||
export_order = fields
|
export_order = fields
|
||||||
import_id_fields = ('id',)
|
import_id_fields = ('id',)
|
||||||
skip_unchanged = True
|
skip_unchanged = True
|
||||||
@@ -127,7 +127,7 @@ class PlanResource(resources.ModelResource):
|
|||||||
class PlanAdmin(ImportExportMixin, Igny8ModelAdmin):
|
class PlanAdmin(ImportExportMixin, Igny8ModelAdmin):
|
||||||
resource_class = PlanResource
|
resource_class = PlanResource
|
||||||
"""Plan admin - Global, no account filtering needed"""
|
"""Plan admin - Global, no account filtering needed"""
|
||||||
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_content_words', 'included_credits', 'is_active', 'is_featured']
|
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured']
|
||||||
list_filter = ['is_active', 'billing_cycle', 'is_internal', 'is_featured']
|
list_filter = ['is_active', 'billing_cycle', 'is_internal', 'is_featured']
|
||||||
search_fields = ['name', 'slug']
|
search_fields = ['name', 'slug']
|
||||||
readonly_fields = ['created_at']
|
readonly_fields = ['created_at']
|
||||||
@@ -147,12 +147,12 @@ class PlanAdmin(ImportExportMixin, Igny8ModelAdmin):
|
|||||||
'description': 'Persistent limits for account-level resources'
|
'description': 'Persistent limits for account-level resources'
|
||||||
}),
|
}),
|
||||||
('Hard Limits (Persistent)', {
|
('Hard Limits (Persistent)', {
|
||||||
'fields': ('max_keywords', 'max_clusters'),
|
'fields': ('max_keywords',),
|
||||||
'description': 'Total allowed - never reset'
|
'description': 'Total allowed - never reset'
|
||||||
}),
|
}),
|
||||||
('Monthly Limits (Reset on Billing Cycle)', {
|
('Monthly Limits (Reset on Billing Cycle)', {
|
||||||
'fields': ('max_content_ideas', 'max_content_words', 'max_images_basic', 'max_images_premium', 'max_image_prompts'),
|
'fields': ('max_ahrefs_queries',),
|
||||||
'description': 'Monthly allowances - reset at billing cycle'
|
'description': 'Monthly Ahrefs keyword research queries (0 = disabled)'
|
||||||
}),
|
}),
|
||||||
('Billing & Credits', {
|
('Billing & Credits', {
|
||||||
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
||||||
|
|||||||
@@ -25,18 +25,7 @@ class Command(BaseCommand):
|
|||||||
'max_users': 999999,
|
'max_users': 999999,
|
||||||
'max_sites': 999999,
|
'max_sites': 999999,
|
||||||
'max_keywords': 999999,
|
'max_keywords': 999999,
|
||||||
'max_clusters': 999999,
|
'max_ahrefs_queries': 999999,
|
||||||
'max_content_ideas': 999999,
|
|
||||||
'monthly_word_count_limit': 999999999,
|
|
||||||
'daily_content_tasks': 999999,
|
|
||||||
'daily_ai_requests': 999999,
|
|
||||||
'daily_ai_request_limit': 999999,
|
|
||||||
'monthly_ai_credit_limit': 999999,
|
|
||||||
'monthly_image_count': 999999,
|
|
||||||
'daily_image_generation_limit': 999999,
|
|
||||||
'monthly_cluster_ai_credits': 999999,
|
|
||||||
'monthly_content_ai_credits': 999999,
|
|
||||||
'monthly_image_ai_credits': 999999,
|
|
||||||
'included_credits': 999999,
|
'included_credits': 999999,
|
||||||
'is_active': True,
|
'is_active': True,
|
||||||
'features': ['ai_writer', 'image_gen', 'auto_publish', 'custom_prompts', 'unlimited'],
|
'features': ['ai_writer', 'image_gen', 'auto_publish', 'custom_prompts', 'unlimited'],
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Generated by IGNY8 Phase 1: Simplify Credits & Limits
|
||||||
|
# Migration: Remove unused limit fields, add Ahrefs query tracking
|
||||||
|
# Date: January 5, 2026
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.core.validators
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""
|
||||||
|
Simplify the credits and limits system:
|
||||||
|
|
||||||
|
PLAN MODEL:
|
||||||
|
- REMOVE: max_clusters, max_content_ideas, max_content_words,
|
||||||
|
max_images_basic, max_images_premium, max_image_prompts
|
||||||
|
- ADD: max_ahrefs_queries (monthly keyword research queries)
|
||||||
|
|
||||||
|
ACCOUNT MODEL:
|
||||||
|
- REMOVE: usage_content_ideas, usage_content_words, usage_images_basic,
|
||||||
|
usage_images_premium, usage_image_prompts
|
||||||
|
- ADD: usage_ahrefs_queries
|
||||||
|
|
||||||
|
RATIONALE:
|
||||||
|
All consumption is now controlled by credits only. The only non-credit
|
||||||
|
limits are: sites, users, keywords (hard limits) and ahrefs_queries (monthly).
|
||||||
|
"""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0018_add_country_remove_intent_seedkeyword'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# STEP 1: Add new Ahrefs fields FIRST (before removing old ones)
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_ahrefs_queries',
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
validators=[django.core.validators.MinValueValidator(0)],
|
||||||
|
help_text='Monthly Ahrefs keyword research queries (0 = disabled)'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_ahrefs_queries',
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
validators=[django.core.validators.MinValueValidator(0)],
|
||||||
|
help_text='Ahrefs queries used this month'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# STEP 2: Remove unused Plan fields
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_clusters',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_content_ideas',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_content_words',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_images_basic',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_images_premium',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_image_prompts',
|
||||||
|
),
|
||||||
|
|
||||||
|
# STEP 3: Remove unused Account fields
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_content_ideas',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_content_words',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_images_basic',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_images_premium',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_image_prompts',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2026-01-06 00:11
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0019_simplify_credits_limits'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='usage_content_ideas',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='usage_content_words',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='usage_image_prompts',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='usage_images_basic',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='usage_images_premium',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='usage_ahrefs_queries',
|
||||||
|
field=models.IntegerField(default=0, help_text='Ahrefs queries used this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -108,11 +108,7 @@ class Account(SoftDeletableModel):
|
|||||||
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
|
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
|
||||||
|
|
||||||
# Monthly usage tracking (reset on billing cycle)
|
# Monthly usage tracking (reset on billing cycle)
|
||||||
usage_content_ideas = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content ideas generated this month")
|
usage_ahrefs_queries = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Ahrefs queries used this month")
|
||||||
usage_content_words = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content words generated this month")
|
|
||||||
usage_images_basic = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Basic AI images this month")
|
|
||||||
usage_images_premium = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Premium AI images this month")
|
|
||||||
usage_image_prompts = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Image prompts this month")
|
|
||||||
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
|
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
|
||||||
usage_period_end = models.DateTimeField(null=True, blank=True, help_text="Current billing period end")
|
usage_period_end = models.DateTimeField(null=True, blank=True, help_text="Current billing period end")
|
||||||
|
|
||||||
@@ -216,37 +212,12 @@ class Plan(models.Model):
|
|||||||
validators=[MinValueValidator(1)],
|
validators=[MinValueValidator(1)],
|
||||||
help_text="Maximum total keywords allowed (hard limit)"
|
help_text="Maximum total keywords allowed (hard limit)"
|
||||||
)
|
)
|
||||||
max_clusters = models.IntegerField(
|
|
||||||
default=100,
|
|
||||||
validators=[MinValueValidator(1)],
|
|
||||||
help_text="Maximum AI keyword clusters allowed (hard limit)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Monthly Limits (Reset on billing cycle)
|
# Monthly Limits (Reset on billing cycle)
|
||||||
max_content_ideas = models.IntegerField(
|
max_ahrefs_queries = models.IntegerField(
|
||||||
default=300,
|
default=0,
|
||||||
validators=[MinValueValidator(1)],
|
|
||||||
help_text="Maximum AI content ideas per month"
|
|
||||||
)
|
|
||||||
max_content_words = models.IntegerField(
|
|
||||||
default=100000,
|
|
||||||
validators=[MinValueValidator(1)],
|
|
||||||
help_text="Maximum content words per month (e.g., 100000 = 100K words)"
|
|
||||||
)
|
|
||||||
max_images_basic = models.IntegerField(
|
|
||||||
default=300,
|
|
||||||
validators=[MinValueValidator(0)],
|
validators=[MinValueValidator(0)],
|
||||||
help_text="Maximum basic AI images per month"
|
help_text="Monthly Ahrefs keyword research queries (0 = disabled)"
|
||||||
)
|
|
||||||
max_images_premium = models.IntegerField(
|
|
||||||
default=60,
|
|
||||||
validators=[MinValueValidator(0)],
|
|
||||||
help_text="Maximum premium AI images per month (DALL-E)"
|
|
||||||
)
|
|
||||||
max_image_prompts = models.IntegerField(
|
|
||||||
default=300,
|
|
||||||
validators=[MinValueValidator(0)],
|
|
||||||
help_text="Maximum image prompts per month"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Billing & Credits (Phase 0: Credit-only system)
|
# Billing & Credits (Phase 0: Credit-only system)
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
'id', 'name', 'slug', 'price', 'original_price', 'billing_cycle', 'annual_discount_percent',
|
'id', 'name', 'slug', 'price', 'original_price', 'billing_cycle', 'annual_discount_percent',
|
||||||
'is_featured', 'features', 'is_active',
|
'is_featured', 'features', 'is_active',
|
||||||
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
||||||
'max_keywords', 'max_clusters',
|
'max_keywords', 'max_ahrefs_queries',
|
||||||
'max_content_ideas', 'max_content_words',
|
|
||||||
'max_images_basic', 'max_images_premium', 'max_image_prompts',
|
|
||||||
'included_credits', 'extra_credit_price', 'allow_credit_topup',
|
'included_credits', 'extra_credit_price', 'allow_credit_topup',
|
||||||
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
|
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
|
||||||
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
|
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Management command to backfill usage tracking for existing content.
|
Management command to backfill usage tracking for existing content.
|
||||||
Usage: python manage.py backfill_usage [account_id]
|
Usage: python manage.py backfill_usage [account_id]
|
||||||
|
|
||||||
|
NOTE: Since the simplification of limits (Jan 2026), this command only
|
||||||
|
tracks Ahrefs queries. All other usage is tracked via CreditUsageLog.
|
||||||
"""
|
"""
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
@@ -9,7 +12,7 @@ from igny8_core.auth.models import Account
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Backfill usage tracking for existing content'
|
help = 'Backfill usage tracking for existing content (Ahrefs queries only)'
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -30,10 +33,6 @@ class Command(BaseCommand):
|
|||||||
else:
|
else:
|
||||||
accounts = Account.objects.filter(plan__isnull=False).select_related('plan')
|
accounts = Account.objects.filter(plan__isnull=False).select_related('plan')
|
||||||
|
|
||||||
ContentIdeas = apps.get_model('planner', 'ContentIdeas')
|
|
||||||
Content = apps.get_model('writer', 'Content')
|
|
||||||
Images = apps.get_model('writer', 'Images')
|
|
||||||
|
|
||||||
total_accounts = accounts.count()
|
total_accounts = accounts.count()
|
||||||
self.stdout.write(f'Processing {total_accounts} account(s)...\n')
|
self.stdout.write(f'Processing {total_accounts} account(s)...\n')
|
||||||
|
|
||||||
@@ -43,45 +42,14 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f'Plan: {account.plan.name if account.plan else "No Plan"}')
|
self.stdout.write(f'Plan: {account.plan.name if account.plan else "No Plan"}')
|
||||||
self.stdout.write('=' * 60)
|
self.stdout.write('=' * 60)
|
||||||
|
|
||||||
# Count content ideas
|
# Ahrefs queries are tracked in CreditUsageLog with operation_type='ahrefs_query'
|
||||||
ideas_count = ContentIdeas.objects.filter(account=account).count()
|
# We don't backfill these as they should be tracked in real-time going forward
|
||||||
self.stdout.write(f'Content Ideas: {ideas_count}')
|
# This command is primarily for verification
|
||||||
|
|
||||||
# Count content words
|
self.stdout.write(f'Ahrefs queries used this month: {account.usage_ahrefs_queries}')
|
||||||
from django.db.models import Sum
|
self.stdout.write(self.style.SUCCESS('\n✅ Verified usage tracking'))
|
||||||
total_words = Content.objects.filter(account=account).aggregate(
|
self.stdout.write(f' usage_ahrefs_queries: {account.usage_ahrefs_queries}\n')
|
||||||
total=Sum('word_count')
|
|
||||||
)['total'] or 0
|
|
||||||
self.stdout.write(f'Content Words: {total_words}')
|
|
||||||
|
|
||||||
# Count images
|
|
||||||
total_images = Images.objects.filter(account=account).count()
|
|
||||||
images_with_prompts = Images.objects.filter(
|
|
||||||
account=account, prompt__isnull=False
|
|
||||||
).exclude(prompt='').count()
|
|
||||||
self.stdout.write(f'Total Images: {total_images}')
|
|
||||||
self.stdout.write(f'Images with Prompts: {images_with_prompts}')
|
|
||||||
|
|
||||||
# Update account usage fields
|
|
||||||
with transaction.atomic():
|
|
||||||
account.usage_content_ideas = ideas_count
|
|
||||||
account.usage_content_words = total_words
|
|
||||||
account.usage_images_basic = total_images
|
|
||||||
account.usage_images_premium = 0 # Premium not implemented yet
|
|
||||||
account.usage_image_prompts = images_with_prompts
|
|
||||||
account.save(update_fields=[
|
|
||||||
'usage_content_ideas', 'usage_content_words',
|
|
||||||
'usage_images_basic', 'usage_images_premium', 'usage_image_prompts',
|
|
||||||
'updated_at'
|
|
||||||
])
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS('\n✅ Updated usage tracking:'))
|
|
||||||
self.stdout.write(f' usage_content_ideas: {account.usage_content_ideas}')
|
|
||||||
self.stdout.write(f' usage_content_words: {account.usage_content_words}')
|
|
||||||
self.stdout.write(f' usage_images_basic: {account.usage_images_basic}')
|
|
||||||
self.stdout.write(f' usage_images_premium: {account.usage_images_premium}')
|
|
||||||
self.stdout.write(f' usage_image_prompts: {account.usage_image_prompts}\n')
|
|
||||||
|
|
||||||
self.stdout.write('=' * 60)
|
self.stdout.write('=' * 60)
|
||||||
self.stdout.write(self.style.SUCCESS('✅ Backfill complete!'))
|
self.stdout.write(self.style.SUCCESS('✅ Verification complete!'))
|
||||||
self.stdout.write('=' * 60)
|
self.stdout.write('=' * 60)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Limit Service for Plan Limit Enforcement
|
Limit Service for Plan Limit Enforcement
|
||||||
Manages hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images, prompts)
|
Manages hard limits (sites, users, keywords) and monthly limits (ahrefs_queries)
|
||||||
"""
|
"""
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -18,12 +18,12 @@ class LimitExceededError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class HardLimitExceededError(LimitExceededError):
|
class HardLimitExceededError(LimitExceededError):
|
||||||
"""Raised when a hard limit (sites, users, keywords, clusters) is exceeded"""
|
"""Raised when a hard limit (sites, users, keywords) is exceeded"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MonthlyLimitExceededError(LimitExceededError):
|
class MonthlyLimitExceededError(LimitExceededError):
|
||||||
"""Raised when a monthly limit (ideas, words, images, prompts) is exceeded"""
|
"""Raised when a monthly limit (ahrefs_queries) is exceeded"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ class LimitService:
|
|||||||
"""Service for managing and enforcing plan limits"""
|
"""Service for managing and enforcing plan limits"""
|
||||||
|
|
||||||
# Map limit types to model/field names
|
# Map limit types to model/field names
|
||||||
|
# Simplified to only 3 hard limits: sites, users, keywords
|
||||||
HARD_LIMIT_MAPPINGS = {
|
HARD_LIMIT_MAPPINGS = {
|
||||||
'sites': {
|
'sites': {
|
||||||
'model': 'igny8_core_auth.Site',
|
'model': 'igny8_core_auth.Site',
|
||||||
@@ -39,10 +40,10 @@ class LimitService:
|
|||||||
'filter_field': 'account',
|
'filter_field': 'account',
|
||||||
},
|
},
|
||||||
'users': {
|
'users': {
|
||||||
'model': 'igny8_core_auth.SiteUserAccess',
|
'model': 'igny8_core_auth.User',
|
||||||
'plan_field': 'max_users',
|
'plan_field': 'max_users',
|
||||||
'display_name': 'Team Users',
|
'display_name': 'Team Members',
|
||||||
'filter_field': 'site__account',
|
'filter_field': 'account',
|
||||||
},
|
},
|
||||||
'keywords': {
|
'keywords': {
|
||||||
'model': 'planner.Keywords',
|
'model': 'planner.Keywords',
|
||||||
@@ -50,39 +51,15 @@ class LimitService:
|
|||||||
'display_name': 'Keywords',
|
'display_name': 'Keywords',
|
||||||
'filter_field': 'account',
|
'filter_field': 'account',
|
||||||
},
|
},
|
||||||
'clusters': {
|
|
||||||
'model': 'planner.Clusters',
|
|
||||||
'plan_field': 'max_clusters',
|
|
||||||
'display_name': 'Clusters',
|
|
||||||
'filter_field': 'account',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Simplified to only 1 monthly limit: ahrefs_queries
|
||||||
|
# All other consumption is controlled by credits only
|
||||||
MONTHLY_LIMIT_MAPPINGS = {
|
MONTHLY_LIMIT_MAPPINGS = {
|
||||||
'content_ideas': {
|
'ahrefs_queries': {
|
||||||
'plan_field': 'max_content_ideas',
|
'plan_field': 'max_ahrefs_queries',
|
||||||
'usage_field': 'usage_content_ideas',
|
'usage_field': 'usage_ahrefs_queries',
|
||||||
'display_name': 'Content Ideas',
|
'display_name': 'Keyword Research Queries',
|
||||||
},
|
|
||||||
'content_words': {
|
|
||||||
'plan_field': 'max_content_words',
|
|
||||||
'usage_field': 'usage_content_words',
|
|
||||||
'display_name': 'Content Words',
|
|
||||||
},
|
|
||||||
'images_basic': {
|
|
||||||
'plan_field': 'max_images_basic',
|
|
||||||
'usage_field': 'usage_images_basic',
|
|
||||||
'display_name': 'Basic Images',
|
|
||||||
},
|
|
||||||
'images_premium': {
|
|
||||||
'plan_field': 'max_images_premium',
|
|
||||||
'usage_field': 'usage_images_premium',
|
|
||||||
'display_name': 'Premium Images',
|
|
||||||
},
|
|
||||||
'image_prompts': {
|
|
||||||
'plan_field': 'max_image_prompts',
|
|
||||||
'usage_field': 'usage_image_prompts',
|
|
||||||
'display_name': 'Image Prompts',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,11 +295,8 @@ class LimitService:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Summary of reset operation
|
dict: Summary of reset operation
|
||||||
"""
|
"""
|
||||||
account.usage_content_ideas = 0
|
# Reset only ahrefs_queries (the only monthly limit now)
|
||||||
account.usage_content_words = 0
|
account.usage_ahrefs_queries = 0
|
||||||
account.usage_images_basic = 0
|
|
||||||
account.usage_images_premium = 0
|
|
||||||
account.usage_image_prompts = 0
|
|
||||||
|
|
||||||
old_period_end = account.usage_period_end
|
old_period_end = account.usage_period_end
|
||||||
|
|
||||||
@@ -341,8 +315,7 @@ class LimitService:
|
|||||||
account.usage_period_end = new_period_end
|
account.usage_period_end = new_period_end
|
||||||
|
|
||||||
account.save(update_fields=[
|
account.save(update_fields=[
|
||||||
'usage_content_ideas', 'usage_content_words',
|
'usage_ahrefs_queries',
|
||||||
'usage_images_basic', 'usage_images_premium', 'usage_image_prompts',
|
|
||||||
'usage_period_start', 'usage_period_end', 'updated_at'
|
'usage_period_start', 'usage_period_end', 'updated_at'
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -353,5 +326,5 @@ class LimitService:
|
|||||||
'old_period_end': old_period_end.isoformat() if old_period_end else None,
|
'old_period_end': old_period_end.isoformat() if old_period_end else None,
|
||||||
'new_period_start': new_period_start.isoformat(),
|
'new_period_start': new_period_start.isoformat(),
|
||||||
'new_period_end': new_period_end.isoformat(),
|
'new_period_end': new_period_end.isoformat(),
|
||||||
'limits_reset': 5,
|
'limits_reset': 1,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ class DefaultsService:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (Site, PublishingSettings, AutomationConfig)
|
Tuple of (Site, PublishingSettings, AutomationConfig)
|
||||||
"""
|
"""
|
||||||
|
# Check hard limit for sites BEFORE creating
|
||||||
|
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
|
||||||
|
LimitService.check_hard_limit(self.account, 'sites', additional_count=1)
|
||||||
|
|
||||||
# Create the site
|
# Create the site
|
||||||
site = Site.objects.create(
|
site = Site.objects.create(
|
||||||
account=self.account,
|
account=self.account,
|
||||||
|
|||||||
@@ -29,23 +29,10 @@ class Command(BaseCommand):
|
|||||||
],
|
],
|
||||||
'Planner': [
|
'Planner': [
|
||||||
('max_keywords', 'Max Keywords'),
|
('max_keywords', 'Max Keywords'),
|
||||||
('max_clusters', 'Max Clusters'),
|
('max_ahrefs_queries', 'Max Ahrefs Queries'),
|
||||||
('max_content_ideas', 'Max Content Ideas'),
|
|
||||||
('daily_cluster_limit', 'Daily Cluster Limit'),
|
|
||||||
],
|
],
|
||||||
'Writer': [
|
'Credits': [
|
||||||
('monthly_word_count_limit', 'Monthly Word Count Limit'),
|
('included_credits', 'Included Credits'),
|
||||||
('daily_content_tasks', 'Daily Content Tasks'),
|
|
||||||
],
|
|
||||||
'Images': [
|
|
||||||
('monthly_image_count', 'Monthly Image Count'),
|
|
||||||
('daily_image_generation_limit', 'Daily Image Generation Limit'),
|
|
||||||
],
|
|
||||||
'AI Credits': [
|
|
||||||
('monthly_ai_credit_limit', 'Monthly AI Credit Limit'),
|
|
||||||
('monthly_cluster_ai_credits', 'Monthly Cluster AI Credits'),
|
|
||||||
('monthly_content_ai_credits', 'Monthly Content AI Credits'),
|
|
||||||
('monthly_image_ai_credits', 'Monthly Image AI Credits'),
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Billing Module
|
# Billing Module
|
||||||
|
|
||||||
**Last Verified:** January 5, 2026
|
**Last Verified:** January 5, 2026
|
||||||
**Status:** ✅ Active
|
**Status:** ✅ Active (Simplified January 2026)
|
||||||
**Backend Path:** `backend/igny8_core/modules/billing/` + `backend/igny8_core/business/billing/`
|
**Backend Path:** `backend/igny8_core/modules/billing/` + `backend/igny8_core/business/billing/`
|
||||||
**Frontend Path:** `frontend/src/pages/Billing/` + `frontend/src/pages/Account/`
|
**Frontend Path:** `frontend/src/pages/Billing/` + `frontend/src/pages/Account/`
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|------|------|-----------|
|
|------|------|-----------|
|
||||||
| Models | `business/billing/models.py` | `CreditTransaction`, `CreditUsageLog`, `CreditCostConfig`, `AIModelConfig` |
|
| Models | `business/billing/models.py` | `CreditTransaction`, `CreditUsageLog`, `CreditCostConfig`, `AIModelConfig` |
|
||||||
| Service | `business/billing/services/credit_service.py` | `CreditService` |
|
| Service | `business/billing/services/credit_service.py` | `CreditService` |
|
||||||
|
| Limit Service | `business/billing/services/limit_service.py` | `LimitService` (4 limits only) |
|
||||||
| Views | `modules/billing/views.py` | `CreditBalanceViewSet`, `CreditUsageViewSet` |
|
| Views | `modules/billing/views.py` | `CreditBalanceViewSet`, `CreditUsageViewSet` |
|
||||||
| Frontend | `pages/Account/PlansAndBillingPage.tsx` | Plans, credits, billing history |
|
| Frontend | `pages/Account/PlansAndBillingPage.tsx` | Plans, credits, billing history |
|
||||||
| Store | `store/billingStore.ts` | `useBillingStore` |
|
| Store | `store/billingStore.ts` | `useBillingStore` |
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
The Billing module manages:
|
The Billing module manages:
|
||||||
- Credit balance and transactions
|
- Credit balance and transactions
|
||||||
- AI model pricing and credit configuration (v1.4.0)
|
- AI model pricing and credit configuration (v1.4.0)
|
||||||
- Usage tracking and limits
|
- Usage tracking with 4 simplified limits (v1.5.0)
|
||||||
- Plan enforcement
|
- Plan enforcement
|
||||||
- Payment processing
|
- Payment processing
|
||||||
|
|
||||||
@@ -205,17 +206,14 @@ CreditService.add_credits(
|
|||||||
| Sites | `max_sites` | Maximum sites per account |
|
| Sites | `max_sites` | Maximum sites per account |
|
||||||
| Users | `max_users` | Maximum team members |
|
| Users | `max_users` | Maximum team members |
|
||||||
| Keywords | `max_keywords` | Total keywords allowed |
|
| Keywords | `max_keywords` | Total keywords allowed |
|
||||||
| Clusters | `max_clusters` | Total clusters allowed |
|
|
||||||
|
|
||||||
### Monthly Limits (Reset on Billing Cycle)
|
### Monthly Limits (Reset on Billing Cycle)
|
||||||
|
|
||||||
| Limit | Field | Description |
|
| Limit | Field | Description |
|
||||||
|-------|-------|-------------|
|
|-------|-------|-------------|
|
||||||
| Content Ideas | `max_content_ideas` | Ideas per month |
|
| Ahrefs Queries | `max_ahrefs_queries` | Live Ahrefs API queries per month |
|
||||||
| Content Words | `max_content_words` | Words generated per month |
|
|
||||||
| Basic Images | `max_images_basic` | Basic AI images per month |
|
**Note:** As of January 2026, the limit system was simplified from 10+ limits to just 4. Credits handle all AI operation costs (content generation, image generation, clustering, etc.) instead of separate per-operation limits.
|
||||||
| Premium Images | `max_images_premium` | Premium AI images per month |
|
|
||||||
| Image Prompts | `max_image_prompts` | Prompts per month |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -224,9 +222,9 @@ CreditService.add_credits(
|
|||||||
**Component:** `UsageLimitsPanel.tsx`
|
**Component:** `UsageLimitsPanel.tsx`
|
||||||
|
|
||||||
Displays:
|
Displays:
|
||||||
- Progress bars for each limit
|
- Progress bars for 4 limits only (Sites, Users, Keywords, Ahrefs Queries)
|
||||||
- Color coding: blue (safe), yellow (warning), red (critical)
|
- Color coding: blue (safe), yellow (warning), red (critical)
|
||||||
- Days until reset for monthly limits
|
- Days until reset for monthly limits (Ahrefs Queries)
|
||||||
- Upgrade CTA when approaching limits
|
- Upgrade CTA when approaching limits
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -264,17 +262,17 @@ Displays:
|
|||||||
### Plans & Billing (`/account/plans`)
|
### Plans & Billing (`/account/plans`)
|
||||||
|
|
||||||
**Tabs:**
|
**Tabs:**
|
||||||
1. **Current Plan** - Active plan, upgrade options
|
1. **Current Plan** - Active plan details, renewal date, "View Usage" link
|
||||||
2. **Credits Overview** - Balance, usage chart, cost breakdown
|
2. **Upgrade Plan** - Pricing table with plan comparison
|
||||||
3. **Purchase Credits** - Credit packages
|
3. **Billing History** - Invoices and payment history
|
||||||
4. **Billing History** - Invoices and transactions
|
|
||||||
|
|
||||||
### Usage Analytics (`/account/usage`)
|
### Usage Analytics (`/account/usage`)
|
||||||
|
|
||||||
**Tabs:**
|
**Tabs:**
|
||||||
1. **Limits & Usage** - Plan limits with progress bars
|
1. **Limits & Usage** - Plan limits with progress bars (4 limits only)
|
||||||
2. **Activity** - Credit transaction history
|
2. **Credit History** - Credit transaction history
|
||||||
3. **API Usage** - API call statistics
|
3. **Credit Insights** - Charts: credits by type, daily timeline, operations breakdown
|
||||||
|
4. **Activity Log** - API call statistics and operation details
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,169 +1,164 @@
|
|||||||
# Usage & Content System
|
# Credit System
|
||||||
|
|
||||||
**Last Verified:** December 25, 2025
|
**Last Verified:** January 5, 2026
|
||||||
|
**Status:** ✅ Simplified (v1.5.0)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
IGNY8 uses a content-based allowance system. Users see "Content Pieces" while the backend tracks detailed credit consumption for internal cost monitoring.
|
IGNY8 uses a unified credit system where all AI operations consume credits from a single balance. Plan limits are simplified to 4 hard/monthly limits only.
|
||||||
|
|
||||||
**User View:** `47/50 Content Pieces Remaining`
|
|
||||||
**Backend Tracks:** Idea credits, content credits, image credits (for cost analysis)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How It Works
|
## Credit Flow (Verified Architecture)
|
||||||
|
|
||||||
### User-Facing (Simple)
|
|
||||||
|
|
||||||
| What Users See | Description |
|
|
||||||
|----------------|-------------|
|
|
||||||
| **Content Pieces** | Monthly allowance of pages/articles |
|
|
||||||
| **X/Y Remaining** | Used vs total for the month |
|
|
||||||
| **Upgrade Plan** | Get more content pieces |
|
|
||||||
|
|
||||||
### Backend (Detailed - Internal Only)
|
|
||||||
|
|
||||||
| Credit Type | Used For | Tracked For |
|
|
||||||
|-------------|----------|-------------|
|
|
||||||
| Idea Credits | Clustering, idea generation | Cost analysis |
|
|
||||||
| Content Credits | Article generation | Usage limits |
|
|
||||||
| Image Credits | Image generation | Cost analysis |
|
|
||||||
| Optimization Credits | SEO optimization (future) | Cost analysis |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Plan Allowances
|
|
||||||
|
|
||||||
| Plan | Content Pieces/Month | Sites | Users |
|
|
||||||
|------|---------------------|-------|-------|
|
|
||||||
| Starter | 50 | 2 | 2 |
|
|
||||||
| Growth | 200 | 5 | 3 |
|
|
||||||
| Scale | 500 | Unlimited | 5 |
|
|
||||||
|
|
||||||
**Included with every content piece:**
|
|
||||||
- AI keyword clustering
|
|
||||||
- AI idea generation
|
|
||||||
- AI content writing (1000-2000 words)
|
|
||||||
- 3 images (1 featured + 2 in-article)
|
|
||||||
- Internal linking
|
|
||||||
- SEO optimization
|
|
||||||
- WordPress publishing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Soft Limits (Hidden from Users)
|
|
||||||
|
|
||||||
To prevent abuse, the backend enforces hidden limits:
|
|
||||||
|
|
||||||
| Limit | Starter | Growth | Scale |
|
|
||||||
|-------|---------|--------|-------|
|
|
||||||
| Keyword imports/mo | 500 | 2,000 | 5,000 |
|
|
||||||
| Clustering operations | 100 | 400 | 1,000 |
|
|
||||||
| Idea generations | 150 | 600 | 1,500 |
|
|
||||||
| Images generated | 200 | 800 | 2,000 |
|
|
||||||
|
|
||||||
If users hit these limits, they see: "You've reached your preparation limit for this month."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Content Deduction Flow
|
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ CONTENT CREATION FLOW │
|
│ CREDIT FLOW │
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ User clicks Check Generate │
|
│ Plan.included_credits = Monthly allocation (e.g., 10,000) │
|
||||||
│ "Generate" ──────► Allowance ──────► Content │
|
│ ↓ (Added on subscription renewal/approval) │
|
||||||
│ │ │ │
|
│ Account.credits = Current balance (real-time, decremented) │
|
||||||
│ │ Limit │ │
|
│ ↓ (Decremented on each AI operation) │
|
||||||
│ │ Reached ▼ │
|
│ CreditTransaction = Log of all credit changes │
|
||||||
│ ▼ Deduct 1 │
|
│ CreditUsageLog = Detailed operation tracking │
|
||||||
│ Show Upgrade Content │
|
│ │
|
||||||
│ Modal Piece │
|
│ THESE ARE NOT PARALLEL - They serve different purposes: │
|
||||||
|
│ • Plan.included_credits = "How many credits per month" │
|
||||||
|
│ • Account.credits = "How many credits you have RIGHT NOW" │
|
||||||
│ │
|
│ │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Where credits are added to Account.credits:**
|
||||||
|
1. `billing/views.py` - When manual payment is approved
|
||||||
|
2. `payment_service.py` - When credit package purchased
|
||||||
|
3. `credit_service.py` - Generic `add_credits()` method
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Operations & Credit Costs
|
## Simplified Limits (v1.5.0)
|
||||||
|
|
||||||
### Planner Operations
|
### Hard Limits (Never Reset)
|
||||||
|
|
||||||
| Operation | Credits | Type |
|
| Limit | Plan Field | Account Field | Description |
|
||||||
|-----------|---------|------|
|
|-------|------------|---------------|-------------|
|
||||||
| Add keyword | 0 | Free |
|
| Sites | `max_sites` | (count of Site objects) | Maximum sites per account |
|
||||||
| Auto-cluster keywords | 1 | Idea |
|
| Users | `max_users` | (count of User objects) | Maximum team members |
|
||||||
| Generate content ideas | 1 per idea | Idea |
|
| Keywords | `max_keywords` | (count of Keyword objects) | Total keywords allowed |
|
||||||
|
|
||||||
### Writer Operations
|
### Monthly Limits (Reset on Billing Cycle)
|
||||||
|
|
||||||
| Operation | Credits | Type |
|
| Limit | Plan Field | Account Field | Description |
|
||||||
|-----------|---------|------|
|
|-------|------------|---------------|-------------|
|
||||||
| Create task | 0 | Free |
|
| Ahrefs Queries | `max_ahrefs_queries` | `usage_ahrefs_queries` | Live Ahrefs API queries per month |
|
||||||
| Generate content | 1 | Content |
|
|
||||||
| Regenerate content | 1 | Content |
|
|
||||||
| Generate images | 1 per image | Image |
|
|
||||||
| Regenerate image | 1 | Image |
|
|
||||||
| Edit content | 0 | Free |
|
|
||||||
|
|
||||||
### Automation Operations
|
### Removed Limits (Now Credit-Based)
|
||||||
|
|
||||||
| Operation | Credits | Type |
|
The following limits were removed in v1.5.0 - credits handle these:
|
||||||
|-----------|---------|------|
|
- ~~max_clusters~~ → Credits
|
||||||
| Run automation | Sum of operations | Mixed |
|
- ~~max_content_ideas~~ → Credits
|
||||||
| Pause/resume | 0 | Free |
|
- ~~max_content_words~~ → Credits
|
||||||
|
- ~~max_images_basic~~ → Credits
|
||||||
|
- ~~max_images_premium~~ → Credits
|
||||||
|
- ~~max_image_prompts~~ → Credits
|
||||||
|
|
||||||
### Publisher Operations
|
---
|
||||||
|
|
||||||
| Operation | Credits | Type |
|
## Plan Tiers
|
||||||
|-----------|---------|------|
|
|
||||||
| Publish to WordPress | 0 | Free |
|
|
||||||
| Sync from WordPress | 0 | Free |
|
|
||||||
|
|
||||||
### Optimizer Operations (Future)
|
| Plan | Credits/Month | Sites | Users | Keywords | Ahrefs Queries |
|
||||||
|
|------|---------------|-------|-------|----------|----------------|
|
||||||
|
| Free | 500 | 1 | 1 | 100 | 0 |
|
||||||
|
| Starter | 5,000 | 3 | 2 | 500 | 50 |
|
||||||
|
| Growth | 15,000 | 10 | 5 | 2,000 | 200 |
|
||||||
|
| Scale | 50,000 | Unlimited | 10 | 10,000 | 500 |
|
||||||
|
|
||||||
| Operation | Credits | Type |
|
---
|
||||||
|-----------|---------|------|
|
|
||||||
| Optimize content | 1 | Optimization |
|
## Credit Operations
|
||||||
| Batch optimize | 1 per item | Optimization |
|
|
||||||
|
### Token-Based Operations (Text AI)
|
||||||
|
|
||||||
|
Credits calculated from actual token usage:
|
||||||
|
- `credits = ceil(total_tokens / tokens_per_credit)`
|
||||||
|
- `tokens_per_credit` defined per model in `AIModelConfig`
|
||||||
|
|
||||||
|
| Operation | Model Example | tokens_per_credit |
|
||||||
|
|-----------|---------------|-------------------|
|
||||||
|
| Keyword Clustering | gpt-4o-mini | 10,000 |
|
||||||
|
| Idea Generation | gpt-4o-mini | 10,000 |
|
||||||
|
| Content Generation | gpt-4o | 1,000 |
|
||||||
|
| Content Optimization | gpt-4o-mini | 10,000 |
|
||||||
|
|
||||||
|
### Fixed-Cost Operations (Image AI)
|
||||||
|
|
||||||
|
Credits per image based on quality tier:
|
||||||
|
|
||||||
|
| Quality Tier | Model Example | Credits/Image |
|
||||||
|
|--------------|---------------|---------------|
|
||||||
|
| Basic | runware:97@1 | 1 |
|
||||||
|
| Quality | dall-e-3 | 5 |
|
||||||
|
| Premium | google:4@2 | 15 |
|
||||||
|
|
||||||
|
### Free Operations
|
||||||
|
|
||||||
|
| Operation | Cost |
|
||||||
|
|-----------|------|
|
||||||
|
| Add keyword (manual) | 0 |
|
||||||
|
| Create content task | 0 |
|
||||||
|
| Edit content | 0 |
|
||||||
|
| Publish to WordPress | 0 |
|
||||||
|
| Sync from WordPress | 0 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database Models
|
## Database Models
|
||||||
|
|
||||||
### CreditBalance
|
### Account (Credit Balance)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class CreditBalance(models.Model):
|
class Account(models.Model):
|
||||||
account = models.ForeignKey(Account)
|
credits = models.IntegerField(default=0) # Current balance
|
||||||
site = models.ForeignKey(Site, null=True)
|
usage_ahrefs_queries = models.IntegerField(default=0) # Monthly Ahrefs usage
|
||||||
|
|
||||||
idea_credits = models.IntegerField(default=0)
|
|
||||||
content_credits = models.IntegerField(default=0)
|
|
||||||
image_credits = models.IntegerField(default=0)
|
|
||||||
optimization_credits = models.IntegerField(default=0)
|
|
||||||
|
|
||||||
period_start = models.DateField()
|
|
||||||
period_end = models.DateField()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### CreditUsage
|
### Plan (Allocations)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class CreditUsage(models.Model):
|
class Plan(models.Model):
|
||||||
|
included_credits = models.IntegerField(default=0) # Monthly allocation
|
||||||
|
max_sites = models.IntegerField(default=1)
|
||||||
|
max_users = models.IntegerField(default=1)
|
||||||
|
max_keywords = models.IntegerField(default=100)
|
||||||
|
max_ahrefs_queries = models.IntegerField(default=0) # Monthly Ahrefs limit
|
||||||
|
```
|
||||||
|
|
||||||
|
### CreditTransaction (Ledger)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CreditTransaction(models.Model):
|
||||||
account = models.ForeignKey(Account)
|
account = models.ForeignKey(Account)
|
||||||
site = models.ForeignKey(Site, null=True)
|
transaction_type = models.CharField() # purchase/subscription/refund/deduction
|
||||||
user = models.ForeignKey(User)
|
amount = models.DecimalField() # Positive (add) or negative (deduct)
|
||||||
|
balance_after = models.DecimalField()
|
||||||
credit_type = models.CharField() # idea/content/image/optimization
|
description = models.CharField()
|
||||||
amount = models.IntegerField()
|
created_at = models.DateTimeField()
|
||||||
operation = models.CharField() # generate_content, etc.
|
```
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
### CreditUsageLog (Analytics)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CreditUsageLog(models.Model):
|
||||||
|
account = models.ForeignKey(Account)
|
||||||
|
operation_type = models.CharField() # clustering/content_generation/image_generation
|
||||||
|
credits_used = models.DecimalField()
|
||||||
|
model_used = models.CharField()
|
||||||
|
tokens_input = models.IntegerField()
|
||||||
|
tokens_output = models.IntegerField()
|
||||||
|
created_at = models.DateTimeField()
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -172,26 +167,58 @@ class CreditUsage(models.Model):
|
|||||||
|
|
||||||
### CreditService
|
### CreditService
|
||||||
|
|
||||||
Location: `backend/igny8_core/business/billing/services.py`
|
Location: `backend/igny8_core/business/billing/services/credit_service.py`
|
||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class CreditService:
|
class CreditService:
|
||||||
def check_balance(account, site, credit_type, amount) -> bool:
|
@staticmethod
|
||||||
"""Check if sufficient credits available"""
|
def check_credits(account, required_credits):
|
||||||
|
"""Check if sufficient credits available, raises InsufficientCreditsError if not"""
|
||||||
|
|
||||||
def deduct_credits(account, site, user, credit_type, amount, operation) -> bool:
|
@staticmethod
|
||||||
"""Deduct credits and log usage"""
|
def deduct_credits_for_operation(account, operation_type, model, tokens_in, tokens_out, metadata=None):
|
||||||
|
"""Deduct credits and log usage after AI operation"""
|
||||||
|
|
||||||
def get_balance(account, site) -> CreditBalance:
|
@staticmethod
|
||||||
"""Get current balance"""
|
def add_credits(account, amount, transaction_type, description):
|
||||||
|
"""Add credits (admin/purchase/subscription)"""
|
||||||
|
|
||||||
def reset_monthly_credits(account) -> None:
|
@staticmethod
|
||||||
"""Reset credits at period start"""
|
def calculate_credits_from_tokens(operation_type, tokens_in, tokens_out, model=None):
|
||||||
|
"""Calculate credits based on token usage and model"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### LimitService
|
||||||
|
|
||||||
|
Location: `backend/igny8_core/business/billing/services/limit_service.py`
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LimitService:
|
||||||
|
HARD_LIMIT_MAPPINGS = {
|
||||||
|
'sites': {...},
|
||||||
|
'users': {...},
|
||||||
|
'keywords': {...},
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTHLY_LIMIT_MAPPINGS = {
|
||||||
|
'ahrefs_queries': {...},
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_hard_limit(cls, account, limit_name, additional_count=1):
|
||||||
|
"""Check if adding items would exceed hard limit"""
|
||||||
|
|
||||||
def add_credits(account, credit_type, amount, reason) -> None:
|
@classmethod
|
||||||
"""Add credits (admin/purchase)"""
|
def check_monthly_limit(cls, account, limit_name, additional_count=1):
|
||||||
|
"""Check if operation would exceed monthly limit"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def increment_monthly_usage(cls, account, limit_name, count=1):
|
||||||
|
"""Increment monthly usage counter"""
|
||||||
```
|
```
|
||||||
|
|
||||||
### Usage in AI Operations
|
### Usage in AI Operations
|
||||||
@@ -199,26 +226,23 @@ class CreditService:
|
|||||||
```python
|
```python
|
||||||
# In content generation service
|
# In content generation service
|
||||||
def generate_content(task, user):
|
def generate_content(task, user):
|
||||||
# 1. Check balance
|
account = task.site.account
|
||||||
if not credit_service.check_balance(
|
|
||||||
account=task.site.account,
|
# 1. Pre-check credits (estimated)
|
||||||
site=task.site,
|
estimated_credits = 50 # Estimate for content generation
|
||||||
credit_type='content',
|
CreditService.check_credits(account, estimated_credits)
|
||||||
amount=1
|
|
||||||
):
|
|
||||||
raise InsufficientCreditsError()
|
|
||||||
|
|
||||||
# 2. Execute AI function
|
# 2. Execute AI function
|
||||||
content = ai_engine.generate_content(task)
|
content, usage = ai_engine.generate_content(task)
|
||||||
|
|
||||||
# 3. Deduct credits
|
# 3. Deduct actual credits based on token usage
|
||||||
credit_service.deduct_credits(
|
CreditService.deduct_credits_for_operation(
|
||||||
account=task.site.account,
|
account=account,
|
||||||
site=task.site,
|
operation_type='content_generation',
|
||||||
user=user,
|
model=usage.model,
|
||||||
credit_type='content',
|
tokens_in=usage.input_tokens,
|
||||||
amount=1,
|
tokens_out=usage.output_tokens,
|
||||||
operation='generate_content'
|
metadata={'content_id': content.id}
|
||||||
)
|
)
|
||||||
|
|
||||||
return content
|
return content
|
||||||
@@ -228,19 +252,14 @@ def generate_content(task, user):
|
|||||||
|
|
||||||
## API Responses
|
## API Responses
|
||||||
|
|
||||||
### Successful Deduction
|
### Successful Operation
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": { ... },
|
"data": { ... },
|
||||||
"credits_used": {
|
"credits_used": 15,
|
||||||
"type": "content",
|
"balance": 9985
|
||||||
"amount": 1
|
|
||||||
},
|
|
||||||
"balance": {
|
|
||||||
"content_credits": 49
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -251,10 +270,25 @@ HTTP 402 Payment Required
|
|||||||
|
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "Insufficient content credits",
|
"error": "Insufficient credits",
|
||||||
"code": "INSUFFICIENT_CREDITS",
|
"code": "INSUFFICIENT_CREDITS",
|
||||||
"required": 1,
|
"required": 50,
|
||||||
"available": 0
|
"available": 25
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limit Exceeded
|
||||||
|
|
||||||
|
```json
|
||||||
|
HTTP 402 Payment Required
|
||||||
|
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Keyword limit reached",
|
||||||
|
"code": "HARD_LIMIT_EXCEEDED",
|
||||||
|
"limit": "keywords",
|
||||||
|
"current": 500,
|
||||||
|
"max": 500
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -262,113 +296,125 @@ HTTP 402 Payment Required
|
|||||||
|
|
||||||
## Frontend Handling
|
## Frontend Handling
|
||||||
|
|
||||||
### Balance Display
|
### Credit Balance Display
|
||||||
|
|
||||||
- Header shows credit balances
|
- Header shows current credit balance
|
||||||
- Updates after each operation
|
- Updates after each operation
|
||||||
- Warning at low balance (< 10%)
|
- Warning at low balance (< 10%)
|
||||||
|
|
||||||
### Error Handling
|
### Pre-Operation Check
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// In writer store
|
import { checkCreditsBeforeOperation } from '@/utils/creditCheck';
|
||||||
async generateContent(taskId: string) {
|
import { useInsufficientCreditsModal } from '@/components/billing/InsufficientCreditsModal';
|
||||||
try {
|
|
||||||
const response = await api.generateContent(taskId);
|
function ContentGenerator() {
|
||||||
// Update billing store
|
const { showModal } = useInsufficientCreditsModal();
|
||||||
billingStore.fetchBalance();
|
|
||||||
return response;
|
const handleGenerate = async () => {
|
||||||
} catch (error) {
|
// Check credits before operation
|
||||||
if (error.code === 'INSUFFICIENT_CREDITS') {
|
const check = await checkCreditsBeforeOperation(50); // estimated cost
|
||||||
// Show upgrade modal
|
|
||||||
uiStore.showUpgradeModal();
|
if (!check.hasEnoughCredits) {
|
||||||
|
showModal({
|
||||||
|
requiredCredits: check.requiredCredits,
|
||||||
|
availableCredits: check.availableCredits,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
// Proceed with generation
|
||||||
|
await generateContent();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage Tracking
|
## API Endpoints
|
||||||
|
|
||||||
### Usage Summary Endpoint
|
### Credit Balance
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/billing/usage/summary/?period=month
|
GET /api/v1/billing/balance/
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"period": "2025-01",
|
"credits": 9500,
|
||||||
"usage": {
|
"plan_credits_per_month": 10000,
|
||||||
"idea_credits": 45,
|
"credits_used_this_month": 500,
|
||||||
"content_credits": 23,
|
"credits_remaining": 9500
|
||||||
"image_credits": 67,
|
|
||||||
"optimization_credits": 0
|
|
||||||
},
|
|
||||||
"by_operation": {
|
|
||||||
"auto_cluster": 12,
|
|
||||||
"generate_ideas": 33,
|
|
||||||
"generate_content": 23,
|
|
||||||
"generate_images": 67
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Usage Limits
|
||||||
|
|
||||||
## Automation Credit Estimation
|
|
||||||
|
|
||||||
Before running automation:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/automation/estimate/?site_id=...
|
GET /api/v1/billing/usage/limits/
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"estimated_credits": {
|
"limits": {
|
||||||
"idea_credits": 25,
|
"sites": { "current": 2, "limit": 5, "type": "hard" },
|
||||||
"content_credits": 10,
|
"users": { "current": 2, "limit": 3, "type": "hard" },
|
||||||
"image_credits": 30
|
"keywords": { "current": 847, "limit": 1000, "type": "hard" },
|
||||||
|
"ahrefs_queries": { "current": 23, "limit": 50, "type": "monthly" }
|
||||||
},
|
},
|
||||||
"stages": {
|
"days_until_reset": 18
|
||||||
"clustering": 5,
|
}
|
||||||
"ideas": 20,
|
```
|
||||||
"content": 10,
|
|
||||||
"images": 30
|
### Usage Analytics
|
||||||
},
|
|
||||||
"has_sufficient_credits": true
|
```
|
||||||
|
GET /api/v1/account/usage/analytics/?days=30
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"period_days": 30,
|
||||||
|
"start_date": "2025-12-06",
|
||||||
|
"end_date": "2026-01-05",
|
||||||
|
"current_balance": 9500,
|
||||||
|
"total_usage": 500,
|
||||||
|
"total_purchases": 0,
|
||||||
|
"usage_by_type": [
|
||||||
|
{ "transaction_type": "content_generation", "total": -350, "count": 15 },
|
||||||
|
{ "transaction_type": "image_generation", "total": -100, "count": 20 },
|
||||||
|
{ "transaction_type": "clustering", "total": -50, "count": 10 }
|
||||||
|
],
|
||||||
|
"daily_usage": [
|
||||||
|
{ "date": "2026-01-05", "usage": 25, "purchases": 0, "net": -25 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Credit Reset
|
## Credit Allocation
|
||||||
|
|
||||||
Credits reset monthly based on billing cycle:
|
Credits are added to `Account.credits` when:
|
||||||
|
|
||||||
1. **Monthly Reset Job** runs at period end
|
1. **Subscription Renewal** - `Plan.included_credits` added monthly
|
||||||
2. **Unused credits** do not roll over
|
2. **Payment Approval** - Manual payments approved by admin
|
||||||
3. **Purchased credits** may have different expiry
|
3. **Credit Purchase** - Credit packages bought by user
|
||||||
|
4. **Admin Adjustment** - Manual credit grants/adjustments
|
||||||
|
|
||||||
### Celery Task
|
### Monthly Reset
|
||||||
|
|
||||||
|
Monthly limits (Ahrefs queries) reset on billing cycle:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@celery.task
|
# In Account model
|
||||||
def reset_monthly_credits():
|
def reset_monthly_usage(self):
|
||||||
"""
|
"""Reset monthly usage counters (called on billing cycle renewal)"""
|
||||||
Run daily, resets credits for accounts
|
self.usage_ahrefs_queries = 0
|
||||||
whose period_end is today
|
self.save(update_fields=['usage_ahrefs_queries'])
|
||||||
"""
|
|
||||||
today = date.today()
|
|
||||||
balances = CreditBalance.objects.filter(period_end=today)
|
|
||||||
|
|
||||||
for balance in balances:
|
|
||||||
credit_service.reset_monthly_credits(balance.account)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -380,20 +426,29 @@ def reset_monthly_credits():
|
|||||||
Via Django Admin or API:
|
Via Django Admin or API:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
|
||||||
# Add credits
|
# Add credits
|
||||||
credit_service.add_credits(
|
CreditService.add_credits(
|
||||||
account=account,
|
account=account,
|
||||||
credit_type='content',
|
amount=1000,
|
||||||
amount=100,
|
transaction_type='adjustment',
|
||||||
reason='Customer support adjustment'
|
description='Customer support adjustment'
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Usage Audit
|
### Usage Audit
|
||||||
|
|
||||||
All credit changes logged in `CreditUsage` with:
|
All credit changes logged in `CreditTransaction` with:
|
||||||
- Timestamp
|
- Timestamp
|
||||||
- User who triggered
|
- Transaction type
|
||||||
|
- Amount (positive or negative)
|
||||||
|
- Balance after transaction
|
||||||
|
- Description
|
||||||
|
|
||||||
|
All AI operations logged in `CreditUsageLog` with:
|
||||||
- Operation type
|
- Operation type
|
||||||
- Amount deducted
|
- Credits used
|
||||||
- Related object ID
|
- Model used
|
||||||
|
- Token counts
|
||||||
|
- Related object metadata
|
||||||
|
|||||||
2449
docs/plans/CREDITS-LIMITS-AUDIT-REPORT.md
Normal file
2449
docs/plans/CREDITS-LIMITS-AUDIT-REPORT.md
Normal file
File diff suppressed because it is too large
Load Diff
2578
docs/plans/CREDITS-LIMITS-IMPLEMENTATION-PLAN.md
Normal file
2578
docs/plans/CREDITS-LIMITS-IMPLEMENTATION-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
274
docs/plans/IMPLEMENTATION-SUMMARY.md
Normal file
274
docs/plans/IMPLEMENTATION-SUMMARY.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# Credits & Limits Implementation - Quick Summary
|
||||||
|
|
||||||
|
**Status:** 🚧 READY TO IMPLEMENT
|
||||||
|
**Timeline:** 5 weeks
|
||||||
|
**Priority:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Big Picture
|
||||||
|
|
||||||
|
### What We're Doing
|
||||||
|
Simplifying the IGNY8 credits and limits system from complex (10+ limits) to simple (4 limits only).
|
||||||
|
|
||||||
|
### Core Philosophy
|
||||||
|
**Keep only 4 hard limits. Everything else = credits.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The 4 Limits (FINAL)
|
||||||
|
|
||||||
|
| Limit | Type | What It Controls |
|
||||||
|
|-------|------|-----------------|
|
||||||
|
| **Sites** | Hard | Max sites per account (e.g., 1, 2, 5, unlimited) |
|
||||||
|
| **Team Users** | Hard | Max team members (e.g., 1, 2, 3, 5) |
|
||||||
|
| **Keywords** | Hard | Total keywords in workspace (e.g., 100, 1K, 5K, 20K) |
|
||||||
|
| **Ahrefs Queries** | Monthly | Live keyword research per month (e.g., 0, 50, 200, 500) |
|
||||||
|
|
||||||
|
**Everything else (content, images, ideas, etc.) = credits only.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Gets REMOVED
|
||||||
|
|
||||||
|
### Database Fields to Delete
|
||||||
|
|
||||||
|
**From Plan Model:**
|
||||||
|
- ❌ `max_content_ideas`
|
||||||
|
- ❌ `max_content_words`
|
||||||
|
- ❌ `max_images_basic`
|
||||||
|
- ❌ `max_images_premium`
|
||||||
|
- ❌ `max_image_prompts`
|
||||||
|
- ❌ `max_clusters` (consider merging with keywords)
|
||||||
|
|
||||||
|
**From Account Model:**
|
||||||
|
- ❌ `usage_content_ideas`
|
||||||
|
- ❌ `usage_content_words`
|
||||||
|
- ❌ `usage_images_basic`
|
||||||
|
- ❌ `usage_images_premium`
|
||||||
|
- ❌ `usage_image_prompts`
|
||||||
|
|
||||||
|
### Why?
|
||||||
|
- Confusing for users (double limiting)
|
||||||
|
- Maintenance overhead
|
||||||
|
- Credit system already provides control
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Gets ADDED
|
||||||
|
|
||||||
|
### New Fields
|
||||||
|
|
||||||
|
**Plan Model:**
|
||||||
|
```python
|
||||||
|
max_ahrefs_queries = models.IntegerField(default=50)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Account Model:**
|
||||||
|
```python
|
||||||
|
usage_ahrefs_queries = models.IntegerField(default=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Feature: Keyword Research
|
||||||
|
|
||||||
|
**Two ways to add keywords:**
|
||||||
|
|
||||||
|
1. **Browse Pre-Researched Keywords** (FREE)
|
||||||
|
- IGNY8's global keyword database
|
||||||
|
- Pre-analyzed, ready to use
|
||||||
|
- Limited by: `max_keywords` (workspace limit)
|
||||||
|
|
||||||
|
2. **Research with Ahrefs** (LIMITED)
|
||||||
|
- Live Ahrefs API queries
|
||||||
|
- Fresh, custom keyword data
|
||||||
|
- Limited by: `max_ahrefs_queries` (monthly limit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Changes
|
||||||
|
|
||||||
|
### Plans & Billing Page (Simplified)
|
||||||
|
|
||||||
|
**Current Plan Tab - BEFORE:**
|
||||||
|
- ❌ Credit balance display
|
||||||
|
- ❌ Usage charts
|
||||||
|
- ❌ Limit progress bars
|
||||||
|
- ❌ "Credits used this month" breakdown
|
||||||
|
|
||||||
|
**Current Plan Tab - AFTER:**
|
||||||
|
- ✅ Plan name, price, renewal date
|
||||||
|
- ✅ Brief summary: "50 articles • 2 sites • 2 users"
|
||||||
|
- ✅ Upgrade CTA
|
||||||
|
- ❌ NO detailed usage (moved to Usage page)
|
||||||
|
|
||||||
|
### Usage Page (Enhanced)
|
||||||
|
|
||||||
|
**NEW Tab Structure:**
|
||||||
|
|
||||||
|
1. **Overview** (NEW)
|
||||||
|
- Quick stats cards (credits, sites, users, keywords)
|
||||||
|
- Period selector (7, 30, 90 days)
|
||||||
|
- Top metrics
|
||||||
|
|
||||||
|
2. **Your Limits**
|
||||||
|
- Only 4 limits with progress bars
|
||||||
|
- Sites, Users, Keywords, Ahrefs Queries
|
||||||
|
|
||||||
|
3. **Credit Insights** (NEW)
|
||||||
|
- Credits by Site
|
||||||
|
- Credits by Action Type
|
||||||
|
- Credits by Image Quality (basic/quality/premium)
|
||||||
|
- Credits by Automation
|
||||||
|
- Timeline chart
|
||||||
|
|
||||||
|
4. **Activity Log**
|
||||||
|
- Detailed transaction history
|
||||||
|
- (Renamed from "API Activity")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Implementation Tasks
|
||||||
|
|
||||||
|
### Backend (Week 1-2)
|
||||||
|
|
||||||
|
1. **Remove unused fields**
|
||||||
|
- Create migration to drop fields
|
||||||
|
- Update models, serializers
|
||||||
|
- Remove from LimitService mappings
|
||||||
|
|
||||||
|
2. **Add Ahrefs fields**
|
||||||
|
- Add to Plan and Account models
|
||||||
|
- Add to LimitService mappings
|
||||||
|
- Create Ahrefs service
|
||||||
|
|
||||||
|
3. **Enforce limits properly**
|
||||||
|
- Add keyword limit checks to ALL entry points
|
||||||
|
- Add automation credit pre-check
|
||||||
|
- Validate before all operations
|
||||||
|
|
||||||
|
### Frontend (Week 2-3)
|
||||||
|
|
||||||
|
1. **Clean up Plans & Billing**
|
||||||
|
- Remove duplicate credit/usage data
|
||||||
|
- Keep only financial info
|
||||||
|
|
||||||
|
2. **Enhance Usage page**
|
||||||
|
- Add Overview tab
|
||||||
|
- Add Credit Insights tab with widgets
|
||||||
|
- Multi-dimensional breakdowns
|
||||||
|
|
||||||
|
3. **Build Keyword Research**
|
||||||
|
- Browse panel (existing SeedKeywords)
|
||||||
|
- Ahrefs panel (new)
|
||||||
|
- Query limit indicator
|
||||||
|
|
||||||
|
4. **Update terminology**
|
||||||
|
- Remove "API", "operations"
|
||||||
|
- Use "actions", "activities"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Requirements
|
||||||
|
|
||||||
|
### Must Check BEFORE Every Operation
|
||||||
|
|
||||||
|
**All AI Operations:**
|
||||||
|
```python
|
||||||
|
# 1. Check credits
|
||||||
|
CreditService.check_credits(account, estimated_credits)
|
||||||
|
|
||||||
|
# 2. Execute
|
||||||
|
result = ai_service.execute()
|
||||||
|
|
||||||
|
# 3. Deduct
|
||||||
|
CreditService.deduct_credits_for_operation(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keyword Creation:**
|
||||||
|
```python
|
||||||
|
# Check limit
|
||||||
|
LimitService.check_hard_limit(account, 'keywords', count)
|
||||||
|
|
||||||
|
# Then create
|
||||||
|
Keywords.objects.bulk_create([...])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automation Runs:**
|
||||||
|
```python
|
||||||
|
# Estimate total cost
|
||||||
|
estimated = estimate_automation_cost(config)
|
||||||
|
|
||||||
|
# Check BEFORE starting
|
||||||
|
CreditService.check_credits(account, estimated)
|
||||||
|
|
||||||
|
# Then run
|
||||||
|
execute_automation(config)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- [ ] All unused fields removed
|
||||||
|
- [ ] 4 limits properly enforced
|
||||||
|
- [ ] Credit checks before ALL operations
|
||||||
|
- [ ] Automation pre-checks credits
|
||||||
|
- [ ] No duplicate data across pages
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- [ ] Simple 4-limit model is clear
|
||||||
|
- [ ] Multi-dimensional insights are actionable
|
||||||
|
- [ ] Keyword research flow is intuitive
|
||||||
|
- [ ] Error messages are user-friendly
|
||||||
|
- [ ] Upgrade prompts at right moments
|
||||||
|
|
||||||
|
### Business
|
||||||
|
- [ ] Reduced support questions
|
||||||
|
- [ ] Higher upgrade conversion
|
||||||
|
- [ ] Better credit visibility
|
||||||
|
- [ ] System scales cleanly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Plan Values
|
||||||
|
|
||||||
|
| Plan | Price | Credits/mo | Sites | Users | Keywords | Ahrefs/mo |
|
||||||
|
|------|-------|-----------|-------|-------|----------|-----------|
|
||||||
|
| **Free** | $0 | 2,000 | 1 | 1 | 100 | 0 |
|
||||||
|
| **Starter** | $49 | 10,000 | 2 | 2 | 1,000 | 50 |
|
||||||
|
| **Growth** | $149 | 40,000 | 5 | 3 | 5,000 | 200 |
|
||||||
|
| **Scale** | $399 | 120,000 | ∞ | 5 | 20,000 | 500 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
|
||||||
|
**Week 1:** Backend cleanup (remove fields, add Ahrefs)
|
||||||
|
**Week 2:** Enforcement (keyword limits, automation checks)
|
||||||
|
**Week 3:** Frontend cleanup (remove duplicates, update UI)
|
||||||
|
**Week 4:** New features (Credit Insights, Keyword Research)
|
||||||
|
**Week 5:** Testing & Production deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Review
|
||||||
|
|
||||||
|
**Full Implementation Plan:**
|
||||||
|
- `docs/plans/CREDITS-LIMITS-IMPLEMENTATION-PLAN.md`
|
||||||
|
|
||||||
|
**Current System Docs:**
|
||||||
|
- `docs/10-MODULES/BILLING.md`
|
||||||
|
- `docs/40-WORKFLOWS/CREDIT-SYSTEM.md`
|
||||||
|
|
||||||
|
**Code:**
|
||||||
|
- Backend: `backend/igny8_core/auth/models.py`
|
||||||
|
- Backend: `backend/igny8_core/business/billing/services/limit_service.py`
|
||||||
|
- Frontend: `frontend/src/pages/account/PlansAndBillingPage.tsx`
|
||||||
|
- Frontend: `frontend/src/pages/account/UsageAnalyticsPage.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** ✅ READY FOR TEAM REVIEW AND IMPLEMENTATION
|
||||||
|
|
||||||
|
**Next Step:** Schedule implementation kickoff meeting with backend, frontend, and QA leads.
|
||||||
356
docs/plans/SYSTEM-ARCHITECTURE-DIAGRAM.md
Normal file
356
docs/plans/SYSTEM-ARCHITECTURE-DIAGRAM.md
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
# System Architecture - Before vs After
|
||||||
|
|
||||||
|
## BEFORE (Complex & Confusing)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PLAN MODEL (BEFORE) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Hard Limits (Never Reset): │
|
||||||
|
│ ├─ max_sites ✅ Keep │
|
||||||
|
│ ├─ max_users ✅ Keep │
|
||||||
|
│ ├─ max_keywords ✅ Keep │
|
||||||
|
│ └─ max_clusters ❌ Remove │
|
||||||
|
│ │
|
||||||
|
│ Monthly Limits (Reset Every Month): │
|
||||||
|
│ ├─ max_content_ideas ❌ Remove │
|
||||||
|
│ ├─ max_content_words ❌ Remove │
|
||||||
|
│ ├─ max_images_basic ❌ Remove │
|
||||||
|
│ ├─ max_images_premium ❌ Remove │
|
||||||
|
│ └─ max_image_prompts ❌ Remove │
|
||||||
|
│ │
|
||||||
|
│ Credits: │
|
||||||
|
│ └─ included_credits ✅ Keep │
|
||||||
|
│ │
|
||||||
|
│ TOTAL LIMITS: 10 fields ❌ TOO COMPLEX │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ACCOUNT MODEL (BEFORE) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Credits: │
|
||||||
|
│ └─ credits ✅ Keep │
|
||||||
|
│ │
|
||||||
|
│ Monthly Usage Tracking: │
|
||||||
|
│ ├─ usage_content_ideas ❌ Remove │
|
||||||
|
│ ├─ usage_content_words ❌ Remove │
|
||||||
|
│ ├─ usage_images_basic ❌ Remove │
|
||||||
|
│ ├─ usage_images_premium ❌ Remove │
|
||||||
|
│ └─ usage_image_prompts ❌ Remove │
|
||||||
|
│ │
|
||||||
|
│ Period Tracking: │
|
||||||
|
│ ├─ usage_period_start ✅ Keep │
|
||||||
|
│ └─ usage_period_end ✅ Keep │
|
||||||
|
│ │
|
||||||
|
│ TOTAL USAGE FIELDS: 5 ❌ UNNECESSARY │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
USER CONFUSION:
|
||||||
|
"I have 5000 credits but can't generate content because I hit my
|
||||||
|
monthly word limit? This makes no sense!"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AFTER (Simple & Clear)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PLAN MODEL (AFTER) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ THE ONLY 4 LIMITS: │
|
||||||
|
│ ├─ max_sites (e.g., 1, 2, 5, unlimited) │
|
||||||
|
│ ├─ max_users (e.g., 1, 2, 3, 5) │
|
||||||
|
│ ├─ max_keywords (e.g., 100, 1K, 5K, 20K) │
|
||||||
|
│ └─ max_ahrefs_queries (e.g., 0, 50, 200, 500) [NEW] │
|
||||||
|
│ │
|
||||||
|
│ Credits: │
|
||||||
|
│ └─ included_credits (e.g., 2K, 10K, 40K, 120K) │
|
||||||
|
│ │
|
||||||
|
│ TOTAL LIMITS: 4 fields ✅ SIMPLE & CLEAR │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ACCOUNT MODEL (AFTER) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Credits: │
|
||||||
|
│ └─ credits (current balance) │
|
||||||
|
│ │
|
||||||
|
│ Monthly Usage Tracking: │
|
||||||
|
│ └─ usage_ahrefs_queries (only 1 tracker needed) [NEW] │
|
||||||
|
│ │
|
||||||
|
│ Period Tracking: │
|
||||||
|
│ ├─ usage_period_start │
|
||||||
|
│ └─ usage_period_end │
|
||||||
|
│ │
|
||||||
|
│ TOTAL USAGE FIELDS: 1 ✅ CLEAN │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
USER CLARITY:
|
||||||
|
"I have 5000 credits. I can use them for whatever I need -
|
||||||
|
articles, images, ideas. Simple!"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Structure - Before vs After
|
||||||
|
|
||||||
|
### BEFORE (Duplicate Data Everywhere)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PLANS & BILLING PAGE │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Tab 1: Current Plan │
|
||||||
|
│ ├─ Plan name, price ✅ │
|
||||||
|
│ ├─ Credit balance ⚠️ DUPLICATE (also in Usage) │
|
||||||
|
│ ├─ Credits used ⚠️ DUPLICATE (also in Usage) │
|
||||||
|
│ ├─ Usage charts ⚠️ DUPLICATE (also in Usage) │
|
||||||
|
│ └─ Limit bars ⚠️ DUPLICATE (also in Usage) │
|
||||||
|
│ │
|
||||||
|
│ Tab 2: Upgrade │
|
||||||
|
│ └─ Pricing table ✅ │
|
||||||
|
│ │
|
||||||
|
│ Tab 3: History │
|
||||||
|
│ ├─ Invoices ✅ │
|
||||||
|
│ └─ Transactions ⚠️ PARTIAL DUPLICATE (also in Usage) │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌───────────────────────────────────────────────────────────────────┐
|
||||||
|
│ USAGE PAGE │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Tab 1: Limits & Usage │
|
||||||
|
│ ├─ Credit balance ⚠️ DUPLICATE (also in Plans & Billing) │
|
||||||
|
│ ├─ Credits used ⚠️ DUPLICATE (also in Plans & Billing) │
|
||||||
|
│ ├─ Limit bars ⚠️ DUPLICATE (also in Plans & Billing) │
|
||||||
|
│ └─ 10+ limit types ❌ TOO MANY │
|
||||||
|
│ │
|
||||||
|
│ Tab 2: Credit History │
|
||||||
|
│ └─ Transactions ⚠️ PARTIAL DUPLICATE (also in Plans & Billing)│
|
||||||
|
│ │
|
||||||
|
│ Tab 3: API Activity ❌ TECHNICAL TERMINOLOGY │
|
||||||
|
│ └─ Operation logs │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### AFTER (Clear Separation)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PLANS & BILLING PAGE (Financial Focus) │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Tab 1: Current Plan │
|
||||||
|
│ ├─ Plan name, price, renewal date ✅ │
|
||||||
|
│ ├─ Brief summary: "50 articles • 2 sites • 2 users" ✅ │
|
||||||
|
│ ├─ Upgrade CTA ✅ │
|
||||||
|
│ └─ ❌ NO usage details (moved to Usage page) │
|
||||||
|
│ │
|
||||||
|
│ Tab 2: Upgrade Plan │
|
||||||
|
│ └─ Pricing table ✅ │
|
||||||
|
│ │
|
||||||
|
│ Tab 3: Billing History │
|
||||||
|
│ ├─ Invoices ✅ │
|
||||||
|
│ ├─ Payment methods ✅ │
|
||||||
|
│ └─ Credit purchases ✅ (financial transactions only) │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌───────────────────────────────────────────────────────────────────┐
|
||||||
|
│ USAGE PAGE (Consumption Tracking) │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Tab 1: Overview [NEW] │
|
||||||
|
│ ├─ Quick stats: Credits, Sites, Users, Keywords ✅ │
|
||||||
|
│ ├─ Period selector: 7/30/90 days ✅ │
|
||||||
|
│ └─ Top metrics for selected period ✅ │
|
||||||
|
│ │
|
||||||
|
│ Tab 2: Your Limits │
|
||||||
|
│ └─ ONLY 4 limits with progress bars ✅ │
|
||||||
|
│ ├─ Sites (e.g., 2 / 5) │
|
||||||
|
│ ├─ Users (e.g., 2 / 3) │
|
||||||
|
│ ├─ Keywords (e.g., 847 / 1,000) │
|
||||||
|
│ └─ Ahrefs Queries (e.g., 23 / 50 this month) │
|
||||||
|
│ │
|
||||||
|
│ Tab 3: Credit Insights [NEW] │
|
||||||
|
│ ├─ Credits by Site 📊 │
|
||||||
|
│ ├─ Credits by Action Type 📊 │
|
||||||
|
│ ├─ Credits by Image Quality 📊 │
|
||||||
|
│ ├─ Credits by Automation 📊 │
|
||||||
|
│ └─ Timeline chart 📈 │
|
||||||
|
│ │
|
||||||
|
│ Tab 4: Activity Log (renamed from "API Activity") │
|
||||||
|
│ └─ Detailed transaction history ✅ │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credit Flow - Before vs After
|
||||||
|
|
||||||
|
### BEFORE (Double Limiting)
|
||||||
|
|
||||||
|
```
|
||||||
|
User wants to generate content
|
||||||
|
↓
|
||||||
|
Check 1: Do they have credits? ✅ Yes, 5000 credits
|
||||||
|
↓
|
||||||
|
Check 2: Have they hit monthly word limit? ❌ YES, 100K/100K
|
||||||
|
↓
|
||||||
|
BLOCKED! "You've reached your monthly word limit"
|
||||||
|
↓
|
||||||
|
User: "But I have 5000 credits left! 😤"
|
||||||
|
```
|
||||||
|
|
||||||
|
### AFTER (Simple Credit-Based)
|
||||||
|
|
||||||
|
```
|
||||||
|
User wants to generate content
|
||||||
|
↓
|
||||||
|
Check: Do they have credits? ✅ Yes, 5000 credits
|
||||||
|
↓
|
||||||
|
Generate content (costs ~50 credits based on tokens)
|
||||||
|
↓
|
||||||
|
Deduct credits: 5000 - 50 = 4950 remaining
|
||||||
|
↓
|
||||||
|
User: "Simple! I can use my credits however I want 😊"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keyword Research - NEW Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────────────┐
|
||||||
|
│ KEYWORD RESEARCH PAGE [NEW] │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Tab Selector: │
|
||||||
|
│ [Browse Pre-Researched] [Research with Ahrefs - 42/50 left] │
|
||||||
|
│ │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Option 1: Browse Pre-Researched Keywords (FREE) │
|
||||||
|
│ ─────────────────────────────────────────────── │
|
||||||
|
│ • Global IGNY8 keyword database │
|
||||||
|
│ • Filter by: Industry, Sector, Country │
|
||||||
|
│ • Pre-analyzed metrics: Volume, Difficulty, Opportunity │
|
||||||
|
│ • Free to browse and add │
|
||||||
|
│ • Adds to workspace (counts toward max_keywords) │
|
||||||
|
│ │
|
||||||
|
│ [Industry ▼] [Sector ▼] [Country ▼] [Search...] │
|
||||||
|
│ │
|
||||||
|
│ Results: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Keyword │ Volume │ Diff │ Score │ [Add] │ │
|
||||||
|
│ ├─────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ digital marketing │ 45K │ 65 │ 88/100 │ [Add] │ │
|
||||||
|
│ │ content strategy │ 12K │ 42 │ 92/100 │ [Add] │ │
|
||||||
|
│ │ seo optimization │ 33K │ 58 │ 85/100 │ [Add] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Option 2: Research with Ahrefs (LIMITED) │
|
||||||
|
│ ─────────────────────────────────────────── │
|
||||||
|
│ • Live Ahrefs API queries │
|
||||||
|
│ • Fresh, custom keyword data │
|
||||||
|
│ • Monthly limit: 42 / 50 queries remaining ⚠️ │
|
||||||
|
│ • Resets: February 1, 2026 │
|
||||||
|
│ │
|
||||||
|
│ [Enter seed keyword...] [Research Keywords] │
|
||||||
|
│ │
|
||||||
|
│ ⚠️ Warning: Each search uses 1 query from your monthly limit │
|
||||||
|
│ │
|
||||||
|
│ Results (if searched): │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Keyword │ Volume │ Diff │ CPC │ [Add] │ │
|
||||||
|
│ ├─────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ ai content tools │ 8.9K │ 48 │ $4.50│ [Add] │ │
|
||||||
|
│ │ automated writing │ 3.2K │ 35 │ $3.80│ [Add] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enforcement Points
|
||||||
|
|
||||||
|
### Backend Validation (REQUIRED at these locations)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Keyword Creation
|
||||||
|
├─ Manual keyword creation form
|
||||||
|
├─ SeedKeyword import (from Browse tab)
|
||||||
|
├─ Ahrefs import (from Research tab)
|
||||||
|
├─ CSV/Excel bulk import
|
||||||
|
└─ API endpoint: POST /api/v1/keywords/
|
||||||
|
|
||||||
|
✅ Must check: LimitService.check_hard_limit(account, 'keywords', count)
|
||||||
|
|
||||||
|
2. Site Creation
|
||||||
|
└─ API endpoint: POST /api/v1/sites/
|
||||||
|
|
||||||
|
✅ Must check: LimitService.check_hard_limit(account, 'sites', 1)
|
||||||
|
|
||||||
|
3. User Invitation
|
||||||
|
└─ API endpoint: POST /api/v1/users/invite/
|
||||||
|
|
||||||
|
✅ Must check: LimitService.check_hard_limit(account, 'users', 1)
|
||||||
|
|
||||||
|
4. Ahrefs Query
|
||||||
|
└─ API endpoint: POST /api/v1/keywords/ahrefs/search/
|
||||||
|
|
||||||
|
✅ Must check: LimitService.check_monthly_limit(account, 'ahrefs_queries', 1)
|
||||||
|
✅ Must increment: LimitService.increment_usage(account, 'ahrefs_queries', 1)
|
||||||
|
|
||||||
|
5. All AI Operations (Content, Images, Ideas, etc.)
|
||||||
|
|
||||||
|
✅ Must check: CreditService.check_credits(account, estimated_credits)
|
||||||
|
✅ Must deduct: CreditService.deduct_credits_for_operation(...)
|
||||||
|
|
||||||
|
6. Automation Runs
|
||||||
|
|
||||||
|
✅ Must pre-check: CreditService.check_credits(account, estimated_total)
|
||||||
|
✅ Each stage deducts: CreditService.deduct_credits_for_operation(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Messages (User-Friendly)
|
||||||
|
|
||||||
|
### OLD (Technical)
|
||||||
|
```
|
||||||
|
HTTP 402 - HardLimitExceededError:
|
||||||
|
max_keywords limit reached (1000/1000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### NEW (User-Friendly)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ ⚠️ Keyword Limit Reached │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ You've reached your keyword limit of 1,000 keywords. │
|
||||||
|
│ │
|
||||||
|
│ Current workspace: 1,000 keywords │
|
||||||
|
│ Your plan limit: 1,000 keywords │
|
||||||
|
│ │
|
||||||
|
│ To add more keywords, you can: │
|
||||||
|
│ • Delete unused keywords to free up space │
|
||||||
|
│ • Upgrade to Growth plan (5,000 keywords) │
|
||||||
|
│ │
|
||||||
|
│ [Delete Keywords] [Upgrade Plan] [Cancel] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Visual Reference**
|
||||||
|
|
||||||
|
See `CREDITS-LIMITS-IMPLEMENTATION-PLAN.md` for complete details.
|
||||||
653
docs/plans/phase-3-plan/FINAL-CREDITS-LIMITS-PLAN.md
Normal file
653
docs/plans/phase-3-plan/FINAL-CREDITS-LIMITS-PLAN.md
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
# Final Credits & Limits Implementation Plan
|
||||||
|
|
||||||
|
**Created:** January 5, 2026
|
||||||
|
**Status:** ✅ COMPLETE (Pending UAT & Production Deployment)
|
||||||
|
**Last Updated:** January 5, 2026
|
||||||
|
**Verified Against:** Backend codebase, Frontend codebase, Current implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
After comprehensive codebase review, this document presents the verified and refined implementation plan for simplifying the credits and limits system.
|
||||||
|
|
||||||
|
### Key Finding: Credit Flow is CORRECT ✅
|
||||||
|
|
||||||
|
The current credit architecture is **sound and logical**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CREDIT FLOW (VERIFIED) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Plan.included_credits = Monthly allocation (e.g., 10,000) │
|
||||||
|
│ ↓ (Added on subscription renewal/approval) │
|
||||||
|
│ Account.credits = Current balance (real-time, decremented) │
|
||||||
|
│ ↓ (Decremented on each AI operation) │
|
||||||
|
│ CreditTransaction = Log of all credit changes │
|
||||||
|
│ CreditUsageLog = Detailed operation tracking │
|
||||||
|
│ │
|
||||||
|
│ THESE ARE NOT PARALLEL - They serve different purposes: │
|
||||||
|
│ • Plan.included_credits = "How many credits per month" │
|
||||||
|
│ • Account.credits = "How many credits you have RIGHT NOW" │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Where credits are added to Account.credits:**
|
||||||
|
1. `billing/views.py:445-465` - When manual payment is approved
|
||||||
|
2. `payment_service.py:219-249` - When credit package purchased
|
||||||
|
3. `credit_service.py:413-445` - Generic `add_credits()` method
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: What To KEEP (Verified Working)
|
||||||
|
|
||||||
|
### 1.1 Credit System (NO CHANGES NEEDED)
|
||||||
|
|
||||||
|
| Component | Location | Status |
|
||||||
|
|-----------|----------|--------|
|
||||||
|
| `Account.credits` | `auth/models.py:83` | ✅ Keep - Real-time balance |
|
||||||
|
| `Plan.included_credits` | `auth/models.py:253` | ✅ Keep - Monthly allocation |
|
||||||
|
| `CreditService` | `billing/services/credit_service.py` | ✅ Keep - All methods working |
|
||||||
|
| `CreditTransaction` | `billing/models.py:26-48` | ✅ Keep - Audit trail |
|
||||||
|
| `CreditUsageLog` | `billing/models.py:90-130` | ✅ Keep - Operation tracking |
|
||||||
|
|
||||||
|
### 1.2 Hard Limits to KEEP (4 only)
|
||||||
|
|
||||||
|
| Limit | Plan Field | Current Location | Status |
|
||||||
|
|-------|------------|------------------|--------|
|
||||||
|
| Sites | `max_sites` | `auth/models.py:207` | ✅ Keep |
|
||||||
|
| Users | `max_users` | `auth/models.py:204` | ✅ Keep |
|
||||||
|
| Keywords | `max_keywords` | `auth/models.py:214` | ✅ Keep |
|
||||||
|
| **Ahrefs Queries** | `max_ahrefs_queries` | **NEW** | ➕ Add |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: What To REMOVE (Verified Unused)
|
||||||
|
|
||||||
|
### 2.1 Remove From Plan Model
|
||||||
|
|
||||||
|
These fields create confusing "double limiting" - credits already control consumption:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# auth/models.py - REMOVE these fields (lines 220-251)
|
||||||
|
max_clusters = ... # Remove - no real use
|
||||||
|
max_content_ideas = ... # Remove - credits handle this
|
||||||
|
max_content_words = ... # Remove - credits handle this
|
||||||
|
max_images_basic = ... # Remove - credits handle this
|
||||||
|
max_images_premium = ... # Remove - credits handle this
|
||||||
|
max_image_prompts = ... # Remove - credits handle this
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why remove?** User confusion: "I have credits but can't generate because I hit my word limit?"
|
||||||
|
|
||||||
|
### 2.2 Remove From Account Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
# auth/models.py - REMOVE these fields (lines 108-114)
|
||||||
|
usage_content_ideas = ... # Remove - tracked in CreditUsageLog
|
||||||
|
usage_content_words = ... # Remove - tracked in CreditUsageLog
|
||||||
|
usage_images_basic = ... # Remove - tracked in CreditUsageLog
|
||||||
|
usage_images_premium = ... # Remove - tracked in CreditUsageLog
|
||||||
|
usage_image_prompts = ... # Remove - tracked in CreditUsageLog
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Update LimitService
|
||||||
|
|
||||||
|
```python
|
||||||
|
# limit_service.py - Update HARD_LIMIT_MAPPINGS
|
||||||
|
HARD_LIMIT_MAPPINGS = {
|
||||||
|
'sites': {...}, # Keep
|
||||||
|
'users': {...}, # Keep
|
||||||
|
'keywords': {...}, # Keep
|
||||||
|
# 'clusters': {...}, # REMOVE
|
||||||
|
}
|
||||||
|
|
||||||
|
# limit_service.py - Update MONTHLY_LIMIT_MAPPINGS
|
||||||
|
MONTHLY_LIMIT_MAPPINGS = {
|
||||||
|
'ahrefs_queries': { # NEW - only monthly limit
|
||||||
|
'plan_field': 'max_ahrefs_queries',
|
||||||
|
'usage_field': 'usage_ahrefs_queries',
|
||||||
|
'display_name': 'Keyword Research Queries',
|
||||||
|
},
|
||||||
|
# REMOVE all others (content_ideas, content_words, images_*, image_prompts)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3: Database Migration
|
||||||
|
|
||||||
|
### Migration Script
|
||||||
|
|
||||||
|
```python
|
||||||
|
# migrations/0XXX_simplify_credits_limits.py
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0XXX_previous'), # Update with actual
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# STEP 1: Add new Ahrefs fields
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_ahrefs_queries',
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text='Monthly Ahrefs keyword research queries (0 = disabled)'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_ahrefs_queries',
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text='Ahrefs queries used this month'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# STEP 2: Remove unused Plan fields
|
||||||
|
migrations.RemoveField(model_name='plan', name='max_clusters'),
|
||||||
|
migrations.RemoveField(model_name='plan', name='max_content_ideas'),
|
||||||
|
migrations.RemoveField(model_name='plan', name='max_content_words'),
|
||||||
|
migrations.RemoveField(model_name='plan', name='max_images_basic'),
|
||||||
|
migrations.RemoveField(model_name='plan', name='max_images_premium'),
|
||||||
|
migrations.RemoveField(model_name='plan', name='max_image_prompts'),
|
||||||
|
|
||||||
|
# STEP 3: Remove unused Account fields
|
||||||
|
migrations.RemoveField(model_name='account', name='usage_content_ideas'),
|
||||||
|
migrations.RemoveField(model_name='account', name='usage_content_words'),
|
||||||
|
migrations.RemoveField(model_name='account', name='usage_images_basic'),
|
||||||
|
migrations.RemoveField(model_name='account', name='usage_images_premium'),
|
||||||
|
migrations.RemoveField(model_name='account', name='usage_image_prompts'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4: Frontend Changes
|
||||||
|
|
||||||
|
### 4.1 Plans & Billing Page (Financial Focus)
|
||||||
|
|
||||||
|
**File:** `frontend/src/pages/account/PlansAndBillingPage.tsx`
|
||||||
|
|
||||||
|
**Current (881 lines):** Shows credit usage charts, limit bars = DUPLICATE of Usage page
|
||||||
|
|
||||||
|
**Target:** Pure financial focus
|
||||||
|
- Tab 1: Current Plan → Name, price, renewal date, "View Usage" link
|
||||||
|
- Tab 2: Upgrade Plan → Pricing table (already good)
|
||||||
|
- Tab 3: Billing History → Invoices, payment methods
|
||||||
|
|
||||||
|
**Remove from Current Plan tab:**
|
||||||
|
- Usage charts (move to Usage page)
|
||||||
|
- Credit consumption breakdown (move to Usage page)
|
||||||
|
- Limit progress bars (move to Usage page)
|
||||||
|
|
||||||
|
### 4.2 Usage Analytics Page (Consumption Tracking)
|
||||||
|
|
||||||
|
**File:** `frontend/src/pages/account/UsageAnalyticsPage.tsx`
|
||||||
|
|
||||||
|
**Current (266 lines):** Basic tabs with some usage data
|
||||||
|
|
||||||
|
**Target:** Comprehensive usage tracking
|
||||||
|
|
||||||
|
```
|
||||||
|
Tab 1: Overview (NEW)
|
||||||
|
├── Quick Stats Cards: Credits Balance, Sites, Users, Keywords
|
||||||
|
├── Period Selector: 7 days | 30 days | 90 days
|
||||||
|
└── Key Metrics for Selected Period
|
||||||
|
|
||||||
|
Tab 2: Your Limits (SIMPLIFIED)
|
||||||
|
└── Progress Bars for ONLY 4 limits:
|
||||||
|
├── Sites: 2 / 5
|
||||||
|
├── Users: 2 / 3
|
||||||
|
├── Keywords: 847 / 1,000
|
||||||
|
└── Keyword Research Queries: 23 / 50 this month
|
||||||
|
|
||||||
|
Tab 3: Credit Insights (NEW)
|
||||||
|
├── Credits by Site (pie chart)
|
||||||
|
├── Credits by Action Type (bar chart)
|
||||||
|
├── Credits by Image Quality (basic vs premium)
|
||||||
|
├── Credits by Automation (manual vs automated)
|
||||||
|
└── Timeline Chart (line graph over time)
|
||||||
|
|
||||||
|
Tab 4: Activity Log (renamed from "API Activity")
|
||||||
|
└── Detailed transaction history (existing functionality)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 CreditBalance Interface Update
|
||||||
|
|
||||||
|
**File:** `frontend/src/services/billing.api.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Current (correct - no changes needed)
|
||||||
|
export interface CreditBalance {
|
||||||
|
credits: number; // Account.credits (current balance)
|
||||||
|
plan_credits_per_month: number; // Plan.included_credits
|
||||||
|
credits_used_this_month: number; // Sum of CreditUsageLog this month
|
||||||
|
credits_remaining: number; // = credits (same as current balance)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 UsageSummary Interface Update
|
||||||
|
|
||||||
|
**File:** `frontend/src/services/billing.api.ts` (add new interface)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface UsageSummary {
|
||||||
|
// Hard limits (4 only)
|
||||||
|
hard_limits: {
|
||||||
|
sites: { current: number; limit: number; display_name: string };
|
||||||
|
users: { current: number; limit: number; display_name: string };
|
||||||
|
keywords: { current: number; limit: number; display_name: string };
|
||||||
|
ahrefs_queries: { current: number; limit: number; display_name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Credits
|
||||||
|
credits: {
|
||||||
|
balance: number;
|
||||||
|
plan_allocation: number;
|
||||||
|
used_this_month: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Period info
|
||||||
|
period: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
days_remaining: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5: Keyword Research Feature
|
||||||
|
|
||||||
|
### 5.1 Two-Option Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────────────┐
|
||||||
|
│ KEYWORD RESEARCH PAGE │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ [Browse IGNY8 Database] [Research with Ahrefs - 42/50 left] │
|
||||||
|
│ │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Option 1: Browse Pre-Researched Keywords (FREE) │
|
||||||
|
│ • Global IGNY8 keyword database │
|
||||||
|
│ • Pre-analyzed metrics from our research │
|
||||||
|
│ • Free to browse and add │
|
||||||
|
│ • Counts toward max_keywords when added │
|
||||||
|
│ │
|
||||||
|
├───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Option 2: Research with Ahrefs (LIMITED) │
|
||||||
|
│ • Live Ahrefs API queries │
|
||||||
|
│ • Fresh, custom keyword data │
|
||||||
|
│ • Monthly limit: 42 / 50 queries remaining │
|
||||||
|
│ • ⚠️ Each search uses 1 query from monthly limit │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Plan Tiers for Ahrefs ✅ CONFIGURED
|
||||||
|
|
||||||
|
| Plan | max_ahrefs_queries | Description |
|
||||||
|
|------|-------------------|-------------|
|
||||||
|
| Free | 0 | Browse only (no live Ahrefs) |
|
||||||
|
| Starter | 50 | 50 queries/month |
|
||||||
|
| Growth | 200 | 200 queries/month |
|
||||||
|
| Scale | 500 | 500 queries/month |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 6: Enforcement Points ✅ COMPLETED
|
||||||
|
|
||||||
|
### 6.1 Backend Validation Checklist ✅
|
||||||
|
|
||||||
|
| Check Point | Location | Status |
|
||||||
|
|-------------|----------|--------|
|
||||||
|
| **Keywords** | | |
|
||||||
|
| Manual creation | `planner/views.py:perform_create` | ✅ Implemented |
|
||||||
|
| Bulk import | `planner/views.py:import_keywords` | ✅ Implemented |
|
||||||
|
| **Sites** | | |
|
||||||
|
| Site creation | `defaults_service.py` | ✅ Implemented |
|
||||||
|
| **Users** | | |
|
||||||
|
| User invite | `account_views.py:team_invite` | ✅ Implemented |
|
||||||
|
| **Credits** | | |
|
||||||
|
| All AI ops | `ai/engine.py` | ✅ Pre-flight checks exist |
|
||||||
|
|
||||||
|
### 6.2 Frontend Pre-Check ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
Components created:
|
||||||
|
- `frontend/src/components/billing/InsufficientCreditsModal.tsx` - Modal with upgrade/buy options
|
||||||
|
- `frontend/src/utils/creditCheck.ts` - Pre-flight credit check utility
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Usage example
|
||||||
|
import { checkCreditsBeforeOperation } from '@/utils/creditCheck';
|
||||||
|
import { useInsufficientCreditsModal } from '@/components/billing/InsufficientCreditsModal';
|
||||||
|
|
||||||
|
const { showModal } = useInsufficientCreditsModal();
|
||||||
|
const check = await checkCreditsBeforeOperation(estimatedCost);
|
||||||
|
|
||||||
|
if (!check.hasEnoughCredits) {
|
||||||
|
showModal({
|
||||||
|
requiredCredits: check.requiredCredits,
|
||||||
|
availableCredits: check.availableCredits,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 7: Implementation Timeline
|
||||||
|
|
||||||
|
### Week 1: Backend Foundation ✅ COMPLETED
|
||||||
|
- [x] Create database migration (add Ahrefs fields, remove unused)
|
||||||
|
- [x] Update Plan model (remove 6 fields, add 1)
|
||||||
|
- [x] Update Account model (remove 5 fields, add 1)
|
||||||
|
- [x] Update LimitService (simplify mappings)
|
||||||
|
- [x] Run migration on dev/staging
|
||||||
|
- [x] Update PlanSerializer (fix removed field references)
|
||||||
|
- [x] Update management commands (create_aws_admin_tenant, get_account_limits)
|
||||||
|
|
||||||
|
### Week 2: Backend Enforcement ✅ COMPLETED
|
||||||
|
- [x] Add keyword limit checks at all entry points (perform_create, import_keywords)
|
||||||
|
- [ ] Create Ahrefs query endpoint with limit check (deferred - Ahrefs not yet integrated)
|
||||||
|
- [x] Update usage summary endpoint
|
||||||
|
- [x] Add site limit check (defaults_service.py)
|
||||||
|
- [x] Add user limit check (account_views.py - team invite)
|
||||||
|
- [x] Update API serializers
|
||||||
|
- [x] AI pre-flight credit checks (already in ai/engine.py)
|
||||||
|
|
||||||
|
### Week 3: Frontend - Plans & Billing ✅ COMPLETED
|
||||||
|
- [x] Update billing.api.ts interfaces (Plan, UsageSummary)
|
||||||
|
- [x] Update UsageLimitsPanel.tsx (4 limits only)
|
||||||
|
- [x] Update PlansAndBillingPage.tsx
|
||||||
|
- [x] Update pricing-table component
|
||||||
|
- [x] Update pricingHelpers.ts
|
||||||
|
- [x] Update Plans.tsx, SignUp.tsx, SignUpFormUnified.tsx
|
||||||
|
|
||||||
|
### Week 4: Frontend - Usage Analytics ✅ COMPLETED
|
||||||
|
- [x] UsageAnalyticsPage uses UsageLimitsPanel (already updated)
|
||||||
|
- [x] Your Limits tab shows 4 limits only
|
||||||
|
- [x] Create Credit Insights tab with charts
|
||||||
|
- [x] Overview quick stats visible on all tabs
|
||||||
|
|
||||||
|
### Week 5: Testing & Documentation ✅ COMPLETED
|
||||||
|
- [ ] Run full test suite (pending - manual testing done)
|
||||||
|
- [x] Update API documentation (docs/10-MODULES/BILLING.md)
|
||||||
|
- [x] Update user documentation (docs/40-WORKFLOWS/CREDIT-SYSTEM.md)
|
||||||
|
- [ ] UAT testing (pending)
|
||||||
|
- [ ] Production deployment (pending)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 8: Verification Checklist
|
||||||
|
|
||||||
|
### 8.1 Docker Verification Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enter Django shell
|
||||||
|
docker exec -it igny8_backend python manage.py shell
|
||||||
|
|
||||||
|
# Verify Plan model changes
|
||||||
|
from igny8_core.auth.models import Plan
|
||||||
|
plan = Plan.objects.first()
|
||||||
|
print(f"max_sites: {plan.max_sites}")
|
||||||
|
print(f"max_users: {plan.max_users}")
|
||||||
|
print(f"max_keywords: {plan.max_keywords}")
|
||||||
|
print(f"max_ahrefs_queries: {plan.max_ahrefs_queries}")
|
||||||
|
# These should ERROR after migration:
|
||||||
|
# print(f"max_clusters: {plan.max_clusters}") # Should fail
|
||||||
|
|
||||||
|
# Verify Account model changes
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
account = Account.objects.first()
|
||||||
|
print(f"credits: {account.credits}")
|
||||||
|
print(f"usage_ahrefs_queries: {account.usage_ahrefs_queries}")
|
||||||
|
# These should ERROR after migration:
|
||||||
|
# print(f"usage_content_ideas: {account.usage_content_ideas}") # Should fail
|
||||||
|
|
||||||
|
# Verify LimitService
|
||||||
|
from igny8_core.business.billing.services.limit_service import LimitService
|
||||||
|
print(f"Hard limits: {list(LimitService.HARD_LIMIT_MAPPINGS.keys())}")
|
||||||
|
# Should be: ['sites', 'users', 'keywords']
|
||||||
|
print(f"Monthly limits: {list(LimitService.MONTHLY_LIMIT_MAPPINGS.keys())}")
|
||||||
|
# Should be: ['ahrefs_queries']
|
||||||
|
|
||||||
|
# Verify Credit Flow
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
print("Credit calculation for content:")
|
||||||
|
credits = CreditService.calculate_credits_from_tokens('content_generation', 1000, 500)
|
||||||
|
print(f" 1000 input + 500 output tokens = {credits} credits")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Django Admin Verification
|
||||||
|
|
||||||
|
**Check in browser at `/admin/`:**
|
||||||
|
|
||||||
|
1. **Accounts → Account**
|
||||||
|
- [ ] `credits` field visible
|
||||||
|
- [ ] `usage_ahrefs_queries` field visible
|
||||||
|
- [ ] `usage_content_ideas` NOT visible (removed)
|
||||||
|
|
||||||
|
2. **Accounts → Plan**
|
||||||
|
- [ ] `max_sites`, `max_users`, `max_keywords` visible
|
||||||
|
- [ ] `max_ahrefs_queries` visible
|
||||||
|
- [ ] `max_content_ideas`, `max_content_words` NOT visible (removed)
|
||||||
|
- [ ] `included_credits` visible
|
||||||
|
|
||||||
|
3. **Billing → Credit Transaction**
|
||||||
|
- [ ] Shows all credit additions/deductions
|
||||||
|
- [ ] `balance_after` shows running total
|
||||||
|
|
||||||
|
4. **Billing → Credit Usage Log**
|
||||||
|
- [ ] Shows all AI operations
|
||||||
|
- [ ] `operation_type`, `credits_used`, `tokens_input`, `tokens_output` visible
|
||||||
|
|
||||||
|
### 8.3 Frontend Verification
|
||||||
|
|
||||||
|
**Plans & Billing Page (`/account/billing`):**
|
||||||
|
- [ ] Current Plan tab shows: Plan name, price, renewal date
|
||||||
|
- [ ] Current Plan tab does NOT show usage charts
|
||||||
|
- [ ] "View Usage Details" link works
|
||||||
|
- [ ] Upgrade tab shows pricing table
|
||||||
|
- [ ] Billing History shows invoices
|
||||||
|
|
||||||
|
**Usage Analytics Page (`/account/usage`):**
|
||||||
|
- [ ] Overview tab shows quick stats
|
||||||
|
- [ ] Your Limits tab shows ONLY 4 limits
|
||||||
|
- [ ] Credit Insights tab shows breakdown charts
|
||||||
|
- [ ] Activity Log tab shows transaction history
|
||||||
|
|
||||||
|
**Header Credit Display:**
|
||||||
|
- [ ] Shows current credit balance
|
||||||
|
- [ ] Updates after AI operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 9: Error Messages (User-Friendly)
|
||||||
|
|
||||||
|
### Before (Technical)
|
||||||
|
```
|
||||||
|
HTTP 402 - HardLimitExceededError: max_keywords limit reached
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (User-Friendly)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ ⚠️ Keyword Limit Reached │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ You've reached your keyword limit. │
|
||||||
|
│ │
|
||||||
|
│ Current: 1,000 keywords │
|
||||||
|
│ Your plan allows: 1,000 keywords │
|
||||||
|
│ │
|
||||||
|
│ To add more keywords: │
|
||||||
|
│ • Delete unused keywords │
|
||||||
|
│ • Upgrade your plan │
|
||||||
|
│ │
|
||||||
|
│ [Manage Keywords] [Upgrade Plan] [Cancel] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 10: Risk Mitigation
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Migration breaks production | Test on staging with prod data clone first |
|
||||||
|
| Users lose tracked usage | Keep CreditUsageLog (detailed tracking continues) |
|
||||||
|
| Ahrefs costs spike | Monthly limit enforced server-side |
|
||||||
|
| Credit confusion | Clear documentation + help tooltips |
|
||||||
|
| Rollback needed | Keep migration reversible (add back fields) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Files to Modify
|
||||||
|
|
||||||
|
### Backend Files
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `backend/igny8_core/auth/models.py` | Remove 11 fields, add 2 fields |
|
||||||
|
| `backend/igny8_core/business/billing/services/limit_service.py` | Simplify mappings |
|
||||||
|
| `backend/igny8_core/business/billing/serializers.py` | Update serializers |
|
||||||
|
| `backend/igny8_core/modules/billing/views.py` | Update usage summary |
|
||||||
|
| `backend/igny8_core/admin/` | Update admin panels |
|
||||||
|
| `backend/migrations/` | New migration file |
|
||||||
|
|
||||||
|
### Frontend Files
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `frontend/src/pages/account/PlansAndBillingPage.tsx` | Simplify (remove usage) |
|
||||||
|
| `frontend/src/pages/account/UsageAnalyticsPage.tsx` | Add new tabs |
|
||||||
|
| `frontend/src/services/billing.api.ts` | Update interfaces |
|
||||||
|
| `frontend/src/components/billing/UsageLimitsPanel.tsx` | Show 4 limits only |
|
||||||
|
|
||||||
|
### Documentation Files
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `docs/10-MODULES/BILLING.md` | Update limits documentation |
|
||||||
|
| `docs/40-WORKFLOWS/CREDIT-SYSTEM.md` | Update credit flow docs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The credit system architecture is **fundamentally correct**:
|
||||||
|
- `Plan.included_credits` defines monthly allocation
|
||||||
|
- `Account.credits` tracks real-time balance
|
||||||
|
- Credits are added on subscription renewal/payment approval
|
||||||
|
- Credits are deducted on each AI operation
|
||||||
|
|
||||||
|
**What's broken:**
|
||||||
|
- Too many unused limits causing user confusion
|
||||||
|
- Duplicate data displayed across pages
|
||||||
|
- Monthly limits (content_words, images, etc.) that duplicate what credits already control
|
||||||
|
|
||||||
|
**The fix:**
|
||||||
|
- Simplify to 4 hard limits + credits
|
||||||
|
- Clear page separation (financial vs consumption)
|
||||||
|
- Better UX with multi-dimensional credit insights
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version:** 1.0
|
||||||
|
**Last Updated:** January 5, 2026
|
||||||
|
**Verified By:** Codebase review of backend and frontend
|
||||||
|
|
||||||
|
|
||||||
|
##############################################
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Organized Implementation Changes Plan
|
||||||
|
|
||||||
|
### 1. **Credit Consumption Widget (Rename & Enhance)**
|
||||||
|
|
||||||
|
**Current:** "Where Credits Go" with just a pie chart
|
||||||
|
**New:** "Credit Consumption" with pie chart + detailed table
|
||||||
|
|
||||||
|
| Operation | Credits Used | Items Created |
|
||||||
|
|-----------|-------------|---------------|
|
||||||
|
| Clustering | 150 | 12 clusters |
|
||||||
|
| Ideas | 200 | 45 ideas |
|
||||||
|
| Content | 1,200 | 24 articles |
|
||||||
|
| Image Prompts | 50 | 30 prompts |
|
||||||
|
| Images (Basic) | 100 | 100 images |
|
||||||
|
| Images (Quality) | 250 | 50 images |
|
||||||
|
| Images (Premium) | 450 | 30 images |
|
||||||
|
|
||||||
|
- Pie chart shows credits consumed per operation
|
||||||
|
- Table shows both **credits** AND **output count**
|
||||||
|
- Learn from Planner/Writer footer workflow completion widgets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **New Usage Logs Page** (`/account/usage/logs`)
|
||||||
|
|
||||||
|
**Purpose:** Detailed, filterable, paginated log of all AI operations
|
||||||
|
|
||||||
|
**Layout:** Same as Planner/Writer table pages (consistent template)
|
||||||
|
|
||||||
|
**Table Columns:**
|
||||||
|
| Date | Operation | Details | Credits | Cost (USD) |
|
||||||
|
|------|-----------|---------|-------|--------|---------|------------|
|
||||||
|
| Jan 5, 2:30pm | Content Writing | "SEO Guide for..." | 35 | $0.042 |
|
||||||
|
| Jan 5, 2:15pm | Image Generation | Premium quality | 15 | $0.18 |
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Filters: Operation type, date range, site
|
||||||
|
- Pagination
|
||||||
|
- USD cost calculated from token pricing (from AIModelConfig)
|
||||||
|
- Link to related content where applicable
|
||||||
|
- Export option
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Usage Dashboard - Multi-Dimensional Widgets**
|
||||||
|
|
||||||
|
**Remove:**
|
||||||
|
- ❌ "Operations" summary card (single metrics, not useful)
|
||||||
|
- ❌ "Recent Activity" card (move to Usage Logs page)
|
||||||
|
|
||||||
|
**Add/Enhance:**
|
||||||
|
- ✅ **Credit Consumption** (pie + table as described above)
|
||||||
|
- ✅ **Usage Trend** - Line chart showing daily/weekly credit usage
|
||||||
|
- ✅ **Credits by Site** - If multiple sites, show distribution
|
||||||
|
- ✅ **Image Quality Breakdown** - Basic vs Quality vs Premium usage
|
||||||
|
- ✅ **Top Operations** - Which operations consume most credits
|
||||||
|
- ✅ **Quick link** to Usage Logs page for detailed view
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Data Sources Needed**
|
||||||
|
|
||||||
|
From backend, I'll need to check:
|
||||||
|
- `CreditUsageLog` - Has operation_type, credits_used, model_used, tokens_in, tokens_out
|
||||||
|
- `AIModelConfig` - Has cost_per_1k_input, cost_per_1k_output for USD calculation
|
||||||
|
- Aggregations for: clusters created, ideas generated, articles written, images by tier
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary of Deliverables
|
||||||
|
|
||||||
|
| Item | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 1 | Widget Enhancement | "Credit Consumption" - pie chart + table with credits & output counts |
|
||||||
|
| 2 | New Page | `/account/usage/logs` - Filterable, paginated AI operation logs with USD costs |
|
||||||
|
| 3 | Dashboard Update | Remove weak widgets, add multi-dimensional data-rich charts |
|
||||||
|
| 4 | Sidebar | Add "Usage Logs" as sub-item under Usage Dashboard |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Does this organization look correct?** Should I proceed with implementation, or would you like to adjust any part of this plan?
|
||||||
@@ -61,11 +61,12 @@ const Credits = lazy(() => import("./pages/Billing/Credits"));
|
|||||||
const Transactions = lazy(() => import("./pages/Billing/Transactions"));
|
const Transactions = lazy(() => import("./pages/Billing/Transactions"));
|
||||||
const Usage = lazy(() => import("./pages/Billing/Usage"));
|
const Usage = lazy(() => import("./pages/Billing/Usage"));
|
||||||
const CreditsAndBilling = lazy(() => import("./pages/Settings/CreditsAndBilling"));
|
const CreditsAndBilling = lazy(() => import("./pages/Settings/CreditsAndBilling"));
|
||||||
const PurchaseCreditsPage = lazy(() => import("./pages/account/PurchaseCreditsPage"));
|
|
||||||
const PlansAndBillingPage = lazy(() => import("./pages/account/PlansAndBillingPage"));
|
const PlansAndBillingPage = lazy(() => import("./pages/account/PlansAndBillingPage"));
|
||||||
const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPage"));
|
const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPage"));
|
||||||
// TeamManagementPage - Now integrated as tab in AccountSettingsPage
|
// TeamManagementPage - Now integrated as tab in AccountSettingsPage
|
||||||
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
|
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
|
||||||
|
const UsageDashboardPage = lazy(() => import("./pages/account/UsageDashboardPage"));
|
||||||
|
const UsageLogsPage = lazy(() => import("./pages/account/UsageLogsPage"));
|
||||||
const ContentSettingsPage = lazy(() => import("./pages/account/ContentSettingsPage"));
|
const ContentSettingsPage = lazy(() => import("./pages/account/ContentSettingsPage"));
|
||||||
const NotificationsPage = lazy(() => import("./pages/account/NotificationsPage"));
|
const NotificationsPage = lazy(() => import("./pages/account/NotificationsPage"));
|
||||||
|
|
||||||
@@ -221,12 +222,16 @@ export default function App() {
|
|||||||
<Route path="/account/plans" element={<PlansAndBillingPage />} />
|
<Route path="/account/plans" element={<PlansAndBillingPage />} />
|
||||||
<Route path="/account/plans/upgrade" element={<PlansAndBillingPage />} />
|
<Route path="/account/plans/upgrade" element={<PlansAndBillingPage />} />
|
||||||
<Route path="/account/plans/history" element={<PlansAndBillingPage />} />
|
<Route path="/account/plans/history" element={<PlansAndBillingPage />} />
|
||||||
<Route path="/account/purchase-credits" element={<PurchaseCreditsPage />} />
|
<Route path="/account/purchase-credits" element={<Navigate to="/account/plans" replace />} />
|
||||||
|
|
||||||
{/* Usage - with sub-routes for sidebar navigation */}
|
{/* Usage Dashboard - Single comprehensive page */}
|
||||||
<Route path="/account/usage" element={<UsageAnalyticsPage />} />
|
<Route path="/account/usage" element={<UsageDashboardPage />} />
|
||||||
<Route path="/account/usage/credits" element={<UsageAnalyticsPage />} />
|
{/* Usage Logs - Detailed operation history */}
|
||||||
<Route path="/account/usage/activity" element={<UsageAnalyticsPage />} />
|
<Route path="/account/usage/logs" element={<UsageLogsPage />} />
|
||||||
|
{/* Legacy routes redirect to dashboard */}
|
||||||
|
<Route path="/account/usage/credits" element={<UsageDashboardPage />} />
|
||||||
|
<Route path="/account/usage/insights" element={<UsageDashboardPage />} />
|
||||||
|
<Route path="/account/usage/activity" element={<UsageDashboardPage />} />
|
||||||
|
|
||||||
{/* Content Settings - with sub-routes for sidebar navigation */}
|
{/* Content Settings - with sub-routes for sidebar navigation */}
|
||||||
<Route path="/account/content-settings" element={<ContentSettingsPage />} />
|
<Route path="/account/content-settings" element={<ContentSettingsPage />} />
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|||||||
|
|
||||||
const PLAN_ALLOWED_PATHS = [
|
const PLAN_ALLOWED_PATHS = [
|
||||||
'/account/plans',
|
'/account/plans',
|
||||||
'/account/purchase-credits',
|
|
||||||
'/account/settings',
|
'/account/settings',
|
||||||
'/account/team',
|
'/account/team',
|
||||||
'/account/usage',
|
'/account/usage',
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface Plan {
|
|||||||
max_users: number;
|
max_users: number;
|
||||||
max_sites: number;
|
max_sites: number;
|
||||||
max_keywords: number;
|
max_keywords: number;
|
||||||
monthly_word_count_limit: number;
|
max_ahrefs_queries: number;
|
||||||
included_credits: number;
|
included_credits: number;
|
||||||
features: string[];
|
features: string[];
|
||||||
}
|
}
|
||||||
@@ -260,8 +260,7 @@ export default function SignUpFormUnified({
|
|||||||
features.push(`${plan.max_sites} ${plan.max_sites === 1 ? 'Site' : 'Sites'}`);
|
features.push(`${plan.max_sites} ${plan.max_sites === 1 ? 'Site' : 'Sites'}`);
|
||||||
features.push(`${plan.max_users} ${plan.max_users === 1 ? 'User' : 'Users'}`);
|
features.push(`${plan.max_users} ${plan.max_users === 1 ? 'User' : 'Users'}`);
|
||||||
features.push(`${formatNumber(plan.max_keywords || 0)} Keywords`);
|
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)} Credits/Month`);
|
||||||
features.push(`${formatNumber(plan.included_credits || 0)} AI Credits`);
|
|
||||||
return features;
|
return features;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
450
frontend/src/components/billing/CreditInsightsCharts.tsx
Normal file
450
frontend/src/components/billing/CreditInsightsCharts.tsx
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* Credit Insights Charts Component
|
||||||
|
* Displays credit usage analytics with visual charts
|
||||||
|
* - Donut chart: Credits by operation type
|
||||||
|
* - Line chart: Daily credit usage timeline
|
||||||
|
* - Bar chart: Top credit-consuming operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Chart from 'react-apexcharts';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { Card } from '../ui/card';
|
||||||
|
import { ActivityIcon, TrendingUpIcon, PieChartIcon, BarChart3Icon } from '../../icons';
|
||||||
|
import type { UsageAnalytics } from '../../services/billing.api';
|
||||||
|
|
||||||
|
interface CreditInsightsChartsProps {
|
||||||
|
analytics: UsageAnalytics | null;
|
||||||
|
loading?: boolean;
|
||||||
|
period: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Friendly names for operation types
|
||||||
|
const OPERATION_LABELS: Record<string, string> = {
|
||||||
|
content_generation: 'Content Generation',
|
||||||
|
image_generation: 'Image Generation',
|
||||||
|
keyword_clustering: 'Keyword Clustering',
|
||||||
|
content_analysis: 'Content Analysis',
|
||||||
|
subscription: 'Subscription',
|
||||||
|
purchase: 'Credit Purchase',
|
||||||
|
refund: 'Refund',
|
||||||
|
adjustment: 'Adjustment',
|
||||||
|
grant: 'Credit Grant',
|
||||||
|
deduction: 'Deduction',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHART_COLORS = [
|
||||||
|
'var(--color-brand-500)',
|
||||||
|
'var(--color-purple-500)',
|
||||||
|
'var(--color-success-500)',
|
||||||
|
'var(--color-warning-500)',
|
||||||
|
'var(--color-error-500)',
|
||||||
|
'var(--color-info-500)',
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#f97316', // orange
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CreditInsightsCharts({ analytics, loading, period }: CreditInsightsChartsProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Card key={i} className="p-6 animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
|
||||||
|
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!analytics) {
|
||||||
|
return (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<PieChartIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>No analytics data available</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare data for donut chart (credits by operation type)
|
||||||
|
const usageByType = analytics.usage_by_type.filter(item => Math.abs(item.total) > 0);
|
||||||
|
const donutLabels = usageByType.map(item => OPERATION_LABELS[item.transaction_type] || item.transaction_type.replace(/_/g, ' '));
|
||||||
|
const donutSeries = usageByType.map(item => Math.abs(item.total));
|
||||||
|
|
||||||
|
const donutOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'donut',
|
||||||
|
fontFamily: 'Outfit, sans-serif',
|
||||||
|
},
|
||||||
|
labels: donutLabels,
|
||||||
|
colors: CHART_COLORS.slice(0, donutLabels.length),
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
labels: {
|
||||||
|
colors: 'var(--color-gray-600)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
pie: {
|
||||||
|
donut: {
|
||||||
|
size: '65%',
|
||||||
|
labels: {
|
||||||
|
show: true,
|
||||||
|
name: {
|
||||||
|
show: true,
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
color: 'var(--color-gray-600)',
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
show: true,
|
||||||
|
fontSize: '24px',
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-gray-900)',
|
||||||
|
formatter: (val: string) => parseInt(val).toLocaleString(),
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
show: true,
|
||||||
|
label: 'Total Credits',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
color: 'var(--color-gray-600)',
|
||||||
|
formatter: () => analytics.total_usage.toLocaleString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
y: {
|
||||||
|
formatter: (val: number) => `${val.toLocaleString()} credits`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responsive: [
|
||||||
|
{
|
||||||
|
breakpoint: 480,
|
||||||
|
options: {
|
||||||
|
chart: {
|
||||||
|
width: 300,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare data for timeline chart (daily usage)
|
||||||
|
const dailyData = analytics.daily_usage || [];
|
||||||
|
const timelineCategories = dailyData.map(d => {
|
||||||
|
const date = new Date(d.date);
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
});
|
||||||
|
const usageSeries = dailyData.map(d => Math.abs(d.usage));
|
||||||
|
const purchasesSeries = dailyData.map(d => d.purchases);
|
||||||
|
|
||||||
|
const timelineOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'area',
|
||||||
|
fontFamily: 'Outfit, sans-serif',
|
||||||
|
height: 300,
|
||||||
|
toolbar: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['var(--color-brand-500)', 'var(--color-success-500)'],
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
type: 'gradient',
|
||||||
|
gradient: {
|
||||||
|
shadeIntensity: 1,
|
||||||
|
opacityFrom: 0.4,
|
||||||
|
opacityTo: 0.1,
|
||||||
|
stops: [0, 90, 100],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: timelineCategories,
|
||||||
|
axisBorder: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: 'var(--color-gray-500)',
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
},
|
||||||
|
rotate: -45,
|
||||||
|
rotateAlways: dailyData.length > 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: 'var(--color-gray-500)',
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
},
|
||||||
|
formatter: (val: number) => val.toLocaleString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: 'var(--color-gray-200)',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
labels: {
|
||||||
|
colors: 'var(--color-gray-600)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
y: {
|
||||||
|
formatter: (val: number) => `${val.toLocaleString()} credits`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const timelineSeries = [
|
||||||
|
{ name: 'Credits Used', data: usageSeries },
|
||||||
|
{ name: 'Credits Added', data: purchasesSeries },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Prepare data for bar chart (top operations by count)
|
||||||
|
const operationsByCount = [...analytics.usage_by_type]
|
||||||
|
.filter(item => item.count > 0)
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const barCategories = operationsByCount.map(item =>
|
||||||
|
OPERATION_LABELS[item.transaction_type] || item.transaction_type.replace(/_/g, ' ')
|
||||||
|
);
|
||||||
|
const barSeries = operationsByCount.map(item => item.count);
|
||||||
|
|
||||||
|
const barOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
fontFamily: 'Outfit, sans-serif',
|
||||||
|
height: 300,
|
||||||
|
toolbar: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['var(--color-purple-500)'],
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
borderRadius: 4,
|
||||||
|
barHeight: '60%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: barCategories,
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: 'var(--color-gray-500)',
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: 'var(--color-gray-600)',
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: 'var(--color-gray-200)',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
y: {
|
||||||
|
formatter: (val: number) => `${val.toLocaleString()} operations`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const avgDailyUsage = dailyData.length > 0
|
||||||
|
? Math.round(dailyData.reduce((sum, d) => sum + Math.abs(d.usage), 0) / dailyData.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const peakUsage = dailyData.length > 0
|
||||||
|
? Math.max(...dailyData.map(d => Math.abs(d.usage)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const topOperation = usageByType.length > 0
|
||||||
|
? usageByType.reduce((max, item) => Math.abs(item.total) > Math.abs(max.total) ? item : max, usageByType[0])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||||
|
<TrendingUpIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">Avg Daily Usage</div>
|
||||||
|
<div className="text-xl font-bold text-brand-600 dark:text-brand-400">
|
||||||
|
{avgDailyUsage.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">credits/day</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
|
<BarChart3Icon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">Peak Usage</div>
|
||||||
|
<div className="text-xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
|
{peakUsage.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">credits in one day</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||||
|
<ActivityIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">Top Operation</div>
|
||||||
|
<div className="text-base font-bold text-success-600 dark:text-success-400 truncate max-w-[150px]">
|
||||||
|
{topOperation
|
||||||
|
? (OPERATION_LABELS[topOperation.transaction_type] || topOperation.transaction_type.replace(/_/g, ' '))
|
||||||
|
: 'N/A'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{topOperation ? `${Math.abs(topOperation.total).toLocaleString()} credits` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Usage by Type - Donut Chart */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||||
|
<PieChartIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Credits by Type
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{donutSeries.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
options={donutOptions}
|
||||||
|
series={donutSeries}
|
||||||
|
type="donut"
|
||||||
|
height={320}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<PieChartIcon className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>No usage data for this period</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Operations by Count - Bar Chart */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
|
<BarChart3Icon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Operations Count
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{barSeries.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
options={barOptions}
|
||||||
|
series={[{ name: 'Operations', data: barSeries }]}
|
||||||
|
type="bar"
|
||||||
|
height={320}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<BarChart3Icon className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>No operations in this period</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Timeline - Full Width */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||||
|
<TrendingUpIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Credit Activity Timeline
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Last {period} days
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dailyData.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
options={timelineOptions}
|
||||||
|
series={timelineSeries}
|
||||||
|
type="area"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUpIcon className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>No daily activity data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
frontend/src/components/billing/InsufficientCreditsModal.tsx
Normal file
168
frontend/src/components/billing/InsufficientCreditsModal.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Insufficient Credits Modal
|
||||||
|
* Shows when user doesn't have enough credits for an operation
|
||||||
|
* Provides options to upgrade plan or buy credits
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal } from '../ui/modal';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
import { ZapIcon, TrendingUpIcon, CreditCardIcon } from '../../icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface InsufficientCreditsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
requiredCredits: number;
|
||||||
|
availableCredits: number;
|
||||||
|
operationType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InsufficientCreditsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
requiredCredits,
|
||||||
|
availableCredits,
|
||||||
|
operationType = 'this operation',
|
||||||
|
}: InsufficientCreditsModalProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const shortfall = requiredCredits - availableCredits;
|
||||||
|
|
||||||
|
const handleUpgradePlan = () => {
|
||||||
|
onClose();
|
||||||
|
navigate('/account/billing/upgrade');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuyCredits = () => {
|
||||||
|
onClose();
|
||||||
|
navigate('/account/billing/credits');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewUsage = () => {
|
||||||
|
onClose();
|
||||||
|
navigate('/account/usage');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} showCloseButton={true}>
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
{/* Warning Icon */}
|
||||||
|
<div className="relative flex items-center justify-center w-20 h-20 mx-auto mb-6">
|
||||||
|
<div className="absolute inset-0 bg-warning-100 dark:bg-warning-900/30 rounded-full"></div>
|
||||||
|
<div className="relative bg-warning-500 rounded-full w-14 h-14 flex items-center justify-center">
|
||||||
|
<ZapIcon className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Insufficient Credits
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
You don't have enough credits for {operationType}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Credit Stats */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6">
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Required</div>
|
||||||
|
<div className="text-lg font-bold text-warning-600 dark:text-warning-400">
|
||||||
|
{requiredCredits.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Available</div>
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{availableCredits.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Shortfall</div>
|
||||||
|
<div className="text-lg font-bold text-error-600 dark:text-error-400">
|
||||||
|
{shortfall.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
fullWidth
|
||||||
|
onClick={handleUpgradePlan}
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<TrendingUpIcon className="w-4 h-4" />
|
||||||
|
Upgrade Plan
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
fullWidth
|
||||||
|
onClick={handleBuyCredits}
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<CreditCardIcon className="w-4 h-4" />
|
||||||
|
Buy Credits
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleViewUsage}
|
||||||
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
View Usage Details →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cancel Button */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
tone="neutral"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage insufficient credits modal state
|
||||||
|
*/
|
||||||
|
export function useInsufficientCreditsModal() {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const [modalProps, setModalProps] = React.useState({
|
||||||
|
requiredCredits: 0,
|
||||||
|
availableCredits: 0,
|
||||||
|
operationType: 'this operation',
|
||||||
|
});
|
||||||
|
|
||||||
|
const showInsufficientCreditsModal = (props: {
|
||||||
|
requiredCredits: number;
|
||||||
|
availableCredits: number;
|
||||||
|
operationType?: string;
|
||||||
|
}) => {
|
||||||
|
setModalProps({
|
||||||
|
requiredCredits: props.requiredCredits,
|
||||||
|
availableCredits: props.availableCredits,
|
||||||
|
operationType: props.operationType || 'this operation',
|
||||||
|
});
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => setIsOpen(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
modalProps,
|
||||||
|
showInsufficientCreditsModal,
|
||||||
|
closeModal,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -177,15 +177,11 @@ export default function UsageLimitsPanel() {
|
|||||||
sites: { icon: <GlobeIcon className="w-5 h-5" />, color: 'success' as const },
|
sites: { icon: <GlobeIcon className="w-5 h-5" />, color: 'success' as const },
|
||||||
users: { icon: <UsersIcon className="w-5 h-5" />, color: 'info' as const },
|
users: { icon: <UsersIcon className="w-5 h-5" />, color: 'info' as const },
|
||||||
keywords: { icon: <TagIcon className="w-5 h-5" />, color: 'purple' as const },
|
keywords: { icon: <TagIcon className="w-5 h-5" />, color: 'purple' as const },
|
||||||
clusters: { icon: <TrendingUpIcon className="w-5 h-5" />, color: 'warning' as const },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Simplified to only 1 monthly limit: Ahrefs keyword research queries
|
||||||
const monthlyLimitConfig = {
|
const monthlyLimitConfig = {
|
||||||
content_ideas: { icon: <FileTextIcon className="w-5 h-5" />, color: 'brand' as const },
|
ahrefs_queries: { icon: <TrendingUpIcon className="w-5 h-5" />, color: 'brand' as const },
|
||||||
content_words: { icon: <FileTextIcon className="w-5 h-5" />, color: 'indigo' as const },
|
|
||||||
images_basic: { icon: <ImageIcon className="w-5 h-5" />, color: 'teal' as const },
|
|
||||||
images_premium: { icon: <ZapIcon className="w-5 h-5" />, color: 'cyan' as const },
|
|
||||||
image_prompts: { icon: <ImageIcon className="w-5 h-5" />, color: 'pink' as const },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,11 +24,7 @@ export interface PricingPlan {
|
|||||||
max_sites?: number;
|
max_sites?: number;
|
||||||
max_users?: number;
|
max_users?: number;
|
||||||
max_keywords?: number;
|
max_keywords?: number;
|
||||||
max_clusters?: number;
|
max_ahrefs_queries?: number;
|
||||||
max_content_ideas?: number;
|
|
||||||
max_content_words?: number;
|
|
||||||
max_images_basic?: number;
|
|
||||||
max_images_premium?: number;
|
|
||||||
included_credits?: number;
|
included_credits?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +138,7 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Plan Limits Section */}
|
{/* Plan Limits Section */}
|
||||||
{(plan.max_sites || plan.max_content_words || plan.included_credits) && (
|
{(plan.max_sites || plan.max_keywords || plan.included_credits) && (
|
||||||
<div className="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
|
<div className="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">LIMITS</div>
|
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">LIMITS</div>
|
||||||
{plan.max_sites && (
|
{plan.max_sites && (
|
||||||
@@ -161,27 +157,11 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{plan.max_content_words && (
|
{plan.max_keywords && (
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{(plan.max_content_words / 1000).toLocaleString()}K Words/month
|
{plan.max_keywords.toLocaleString()} Keywords
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{plan.max_content_ideas && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{plan.max_content_ideas} Ideas/month
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{plan.max_images_basic && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{plan.max_images_basic} Images/month
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
@@ -189,7 +169,7 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
|||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
<CheckIcon className="w-4 h-4 text-brand-500 flex-shrink-0 mt-0.5" />
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{plan.included_credits.toLocaleString()} Content pieces/month
|
{plan.included_credits.toLocaleString()} Credits/month
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -212,19 +212,14 @@ const AppSidebar: React.FC = () => {
|
|||||||
{
|
{
|
||||||
icon: <DollarLineIcon />,
|
icon: <DollarLineIcon />,
|
||||||
name: "Plans & Billing",
|
name: "Plans & Billing",
|
||||||
subItems: [
|
path: "/account/plans",
|
||||||
{ name: "Current Plan", path: "/account/plans" },
|
|
||||||
{ name: "Upgrade Plan", path: "/account/plans/upgrade" },
|
|
||||||
{ name: "History", path: "/account/plans/history" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <PieChartIcon />,
|
icon: <PieChartIcon />,
|
||||||
name: "Usage",
|
name: "Usage",
|
||||||
subItems: [
|
subItems: [
|
||||||
{ name: "Limits & Usage", path: "/account/usage" },
|
{ name: "Dashboard", path: "/account/usage" },
|
||||||
{ name: "Credit History", path: "/account/usage/credits" },
|
{ name: "Usage Logs", path: "/account/usage/logs" },
|
||||||
{ name: "Activity", path: "/account/usage/activity" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,16 +13,8 @@ interface Plan {
|
|||||||
max_users: number;
|
max_users: number;
|
||||||
max_sites: number;
|
max_sites: number;
|
||||||
max_keywords: number;
|
max_keywords: number;
|
||||||
max_clusters: number;
|
max_ahrefs_queries: 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;
|
included_credits: number;
|
||||||
image_model_choices: string[];
|
|
||||||
features: string[];
|
features: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,26 +94,25 @@ export default function SeedKeywords() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{keyword.industry_name}
|
{keyword.industry_name}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{keyword.sector_name}
|
{keyword.sector_name}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||||
{keyword.volume.toLocaleString()}
|
{keyword.volume.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||||
{keyword.difficulty}
|
{keyword.difficulty}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<Badge variant="light" color="info">{keyword.country_display}</Badge>
|
<Badge variant="light" color="info">{keyword.country_display}</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,8 @@ interface Plan {
|
|||||||
max_users: number;
|
max_users: number;
|
||||||
max_sites: number;
|
max_sites: number;
|
||||||
max_keywords: number;
|
max_keywords: number;
|
||||||
max_clusters: number;
|
max_ahrefs_queries: 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;
|
included_credits: number;
|
||||||
image_model_choices: string[];
|
|
||||||
features: string[];
|
features: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { TrendingUpIcon, ActivityIcon, BarChart3Icon, ZapIcon, CalendarIcon } from '../../icons';
|
import { TrendingUpIcon, ActivityIcon, BarChart3Icon, ZapIcon, CalendarIcon, PieChartIcon } from '../../icons';
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
@@ -15,13 +15,15 @@ import { Card } from '../../components/ui/card';
|
|||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
import BillingUsagePanel from '../../components/billing/BillingUsagePanel';
|
import BillingUsagePanel from '../../components/billing/BillingUsagePanel';
|
||||||
import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel';
|
import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel';
|
||||||
|
import CreditInsightsCharts from '../../components/billing/CreditInsightsCharts';
|
||||||
import Button from '../../components/ui/button/Button';
|
import Button from '../../components/ui/button/Button';
|
||||||
|
|
||||||
type TabType = 'limits' | 'activity' | 'api';
|
type TabType = 'limits' | 'activity' | 'insights' | 'api';
|
||||||
|
|
||||||
// Map URL paths to tab types
|
// Map URL paths to tab types
|
||||||
function getTabFromPath(pathname: string): TabType {
|
function getTabFromPath(pathname: string): TabType {
|
||||||
if (pathname.includes('/credits')) return 'activity';
|
if (pathname.includes('/credits')) return 'activity';
|
||||||
|
if (pathname.includes('/insights')) return 'insights';
|
||||||
if (pathname.includes('/activity')) return 'api';
|
if (pathname.includes('/activity')) return 'api';
|
||||||
return 'limits';
|
return 'limits';
|
||||||
}
|
}
|
||||||
@@ -59,12 +61,14 @@ export default function UsageAnalyticsPage() {
|
|||||||
const tabTitles: Record<TabType, string> = {
|
const tabTitles: Record<TabType, string> = {
|
||||||
limits: 'Limits & Usage',
|
limits: 'Limits & Usage',
|
||||||
activity: 'Credit History',
|
activity: 'Credit History',
|
||||||
|
insights: 'Credit Insights',
|
||||||
api: 'Activity Log',
|
api: 'Activity Log',
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabDescriptions: Record<TabType, string> = {
|
const tabDescriptions: Record<TabType, string> = {
|
||||||
limits: 'See how much you\'re using - Track your credits and content limits',
|
limits: 'See how much you\'re using - Track your credits and content limits',
|
||||||
activity: 'See where your credits go - Track credit usage history',
|
activity: 'See where your credits go - Track credit usage history',
|
||||||
|
insights: 'Visualize your usage patterns - Charts and analytics',
|
||||||
api: 'Technical requests - Monitor API activity and usage',
|
api: 'Technical requests - Monitor API activity and usage',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,8 +147,8 @@ export default function UsageAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Period Selector (only show on activity and api tabs) */}
|
{/* Period Selector (only show on activity, insights and api tabs) */}
|
||||||
{(activeTab === 'activity' || activeTab === 'api') && (
|
{(activeTab === 'activity' || activeTab === 'api' || activeTab === 'insights') && (
|
||||||
<div className="mb-6 flex justify-end">
|
<div className="mb-6 flex justify-end">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[7, 30, 90].map((value) => {
|
{[7, 30, 90].map((value) => {
|
||||||
@@ -181,6 +185,15 @@ export default function UsageAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Credit Insights Tab */}
|
||||||
|
{activeTab === 'insights' && (
|
||||||
|
<CreditInsightsCharts
|
||||||
|
analytics={analytics}
|
||||||
|
loading={loading}
|
||||||
|
period={period}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* API Usage Tab */}
|
{/* API Usage Tab */}
|
||||||
{activeTab === 'api' && (
|
{activeTab === 'api' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
679
frontend/src/pages/account/UsageDashboardPage.tsx
Normal file
679
frontend/src/pages/account/UsageDashboardPage.tsx
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
/**
|
||||||
|
* Usage Dashboard - Unified Analytics Page
|
||||||
|
* Single comprehensive view of all usage, limits, and credit analytics
|
||||||
|
* Replaces the 4-tab structure with a clean, organized dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Chart from 'react-apexcharts';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import {
|
||||||
|
TrendingUpIcon,
|
||||||
|
ZapIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
UsersIcon,
|
||||||
|
TagIcon,
|
||||||
|
SearchIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
PieChartIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
ImageIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
|
import {
|
||||||
|
getUsageAnalytics,
|
||||||
|
UsageAnalytics,
|
||||||
|
getCreditBalance,
|
||||||
|
type CreditBalance,
|
||||||
|
getUsageSummary,
|
||||||
|
type UsageSummary,
|
||||||
|
type LimitUsage,
|
||||||
|
getCreditUsageSummary,
|
||||||
|
} from '../../services/billing.api';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
|
import Button from '../../components/ui/button/Button';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
// User-friendly operation names - no model/token details
|
||||||
|
const OPERATION_LABELS: Record<string, string> = {
|
||||||
|
content_generation: 'Content Writing',
|
||||||
|
image_generation: 'Image Creation',
|
||||||
|
image_prompt_extraction: 'Image Prompts',
|
||||||
|
keyword_clustering: 'Keyword Clustering',
|
||||||
|
clustering: 'Keyword Clustering',
|
||||||
|
idea_generation: 'Content Ideas',
|
||||||
|
content_analysis: 'Content Analysis',
|
||||||
|
linking: 'Internal Linking',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chart colors - use hex for consistent coloring between pie chart and table
|
||||||
|
const CHART_COLORS = [
|
||||||
|
'#3b82f6', // blue - Content Writing
|
||||||
|
'#ec4899', // pink - Image Prompts
|
||||||
|
'#22c55e', // green - Content Ideas
|
||||||
|
'#f59e0b', // amber - Keyword Clustering
|
||||||
|
'#8b5cf6', // purple - Image Creation
|
||||||
|
'#ef4444', // red
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#6366f1', // indigo
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map operation types to their output unit names
|
||||||
|
const OPERATION_UNITS: Record<string, string> = {
|
||||||
|
content_generation: 'Articles',
|
||||||
|
image_generation: 'Images',
|
||||||
|
image_prompt_extraction: 'Prompts',
|
||||||
|
keyword_clustering: 'Clusters',
|
||||||
|
clustering: 'Clusters',
|
||||||
|
idea_generation: 'Ideas',
|
||||||
|
content_analysis: 'Analyses',
|
||||||
|
linking: 'Links',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UsageDashboardPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [analytics, setAnalytics] = useState<UsageAnalytics | null>(null);
|
||||||
|
const [creditBalance, setCreditBalance] = useState<CreditBalance | null>(null);
|
||||||
|
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
||||||
|
const [creditConsumption, setCreditConsumption] = useState<Record<string, { credits: number; cost: number; count: number }>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [period, setPeriod] = useState(30);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAllData();
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
const loadAllData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// Calculate start date for period
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - period);
|
||||||
|
|
||||||
|
const [analyticsData, balanceData, summaryData, consumptionData] = await Promise.all([
|
||||||
|
getUsageAnalytics(period),
|
||||||
|
getCreditBalance(),
|
||||||
|
getUsageSummary(),
|
||||||
|
getCreditUsageSummary({
|
||||||
|
start_date: startDate.toISOString(),
|
||||||
|
end_date: endDate.toISOString(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setAnalytics(analyticsData);
|
||||||
|
setCreditBalance(balanceData);
|
||||||
|
setUsageSummary(summaryData);
|
||||||
|
setCreditConsumption(consumptionData.by_operation || {});
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to load usage data: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate credit usage percentage
|
||||||
|
const creditPercentage = creditBalance && creditBalance.plan_credits_per_month > 0
|
||||||
|
? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Prepare timeline chart data
|
||||||
|
const dailyData = analytics?.daily_usage || [];
|
||||||
|
const timelineCategories = dailyData.map(d => {
|
||||||
|
const date = new Date(d.date);
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const timelineOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'area',
|
||||||
|
fontFamily: 'Outfit, sans-serif',
|
||||||
|
height: 200,
|
||||||
|
sparkline: { enabled: false },
|
||||||
|
toolbar: { show: false },
|
||||||
|
zoom: { enabled: false },
|
||||||
|
},
|
||||||
|
colors: ['var(--color-brand-500)'],
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
stroke: { curve: 'smooth', width: 2 },
|
||||||
|
fill: {
|
||||||
|
type: 'gradient',
|
||||||
|
gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.1, stops: [0, 90, 100] },
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: timelineCategories,
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
labels: {
|
||||||
|
style: { colors: 'var(--color-gray-500)', fontFamily: 'Outfit' },
|
||||||
|
rotate: -45,
|
||||||
|
rotateAlways: dailyData.length > 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: { colors: 'var(--color-gray-500)', fontFamily: 'Outfit' },
|
||||||
|
formatter: (val: number) => val.toLocaleString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { borderColor: 'var(--color-gray-200)', strokeDashArray: 4 },
|
||||||
|
tooltip: { y: { formatter: (val: number) => `${val.toLocaleString()} credits` } },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare donut chart for credit consumption (from creditConsumption state)
|
||||||
|
// Sort by credits descending so pie chart and table colors match
|
||||||
|
const consumptionEntries = Object.entries(creditConsumption)
|
||||||
|
.filter(([_, data]) => data.credits > 0)
|
||||||
|
.sort((a, b) => b[1].credits - a[1].credits);
|
||||||
|
const donutLabels = consumptionEntries.map(([opType]) => OPERATION_LABELS[opType] || opType.replace(/_/g, ' '));
|
||||||
|
const donutSeries = consumptionEntries.map(([_, data]) => data.credits);
|
||||||
|
const totalCreditsUsed = donutSeries.reduce((sum, val) => sum + val, 0);
|
||||||
|
|
||||||
|
const donutOptions: ApexOptions = {
|
||||||
|
chart: { type: 'donut', fontFamily: 'Outfit, sans-serif' },
|
||||||
|
labels: donutLabels,
|
||||||
|
colors: CHART_COLORS.slice(0, donutLabels.length),
|
||||||
|
legend: { show: false },
|
||||||
|
plotOptions: {
|
||||||
|
pie: {
|
||||||
|
donut: {
|
||||||
|
size: '75%',
|
||||||
|
labels: {
|
||||||
|
show: true,
|
||||||
|
name: { show: true, fontSize: '11px', fontFamily: 'Outfit', color: '#6b7280' },
|
||||||
|
value: { show: true, fontSize: '18px', fontFamily: 'Outfit', fontWeight: 600, color: '#111827', formatter: (val: string) => parseInt(val).toLocaleString() },
|
||||||
|
total: { show: true, label: 'Total Credits', fontSize: '11px', fontFamily: 'Outfit', color: '#6b7280', formatter: () => totalCreditsUsed.toLocaleString() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
tooltip: {
|
||||||
|
custom: function({ series, seriesIndex, w }) {
|
||||||
|
const label = w.globals.labels[seriesIndex];
|
||||||
|
const value = series[seriesIndex];
|
||||||
|
return `<div style="background: #1f2937; color: #fff; padding: 8px 12px; border-radius: 6px; font-size: 12px; font-family: Outfit, sans-serif;">
|
||||||
|
<span style="font-weight: 500;">${label}:</span> ${value.toLocaleString()} credits
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limit card component with Coming Soon support
|
||||||
|
const LimitCard = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
usage,
|
||||||
|
type,
|
||||||
|
color,
|
||||||
|
comingSoon = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
usage: LimitUsage | undefined;
|
||||||
|
type: 'hard' | 'monthly';
|
||||||
|
color: string;
|
||||||
|
comingSoon?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (comingSoon) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-warning-50 dark:bg-warning-900/20 rounded-xl border border-warning-200 dark:border-warning-800">
|
||||||
|
<div className="p-2.5 rounded-lg bg-warning-100 dark:bg-warning-900/30 text-warning-600 dark:text-warning-400">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white text-sm">{title}</span>
|
||||||
|
<Badge variant="soft" tone="warning" size="sm">Coming Soon</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">This feature is not yet available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usage) return null;
|
||||||
|
|
||||||
|
const percentage = usage.percentage_used;
|
||||||
|
const isWarning = percentage >= 80;
|
||||||
|
const isDanger = percentage >= 95;
|
||||||
|
const barColor = isDanger ? 'var(--color-error-500)' : isWarning ? 'var(--color-warning-500)' : color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
|
||||||
|
<div
|
||||||
|
className="p-2.5 rounded-lg shrink-0"
|
||||||
|
style={{ backgroundColor: `${barColor}15`, color: barColor }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white text-sm">{title}</span>
|
||||||
|
<Badge
|
||||||
|
variant="soft"
|
||||||
|
tone={isDanger ? 'danger' : isWarning ? 'warning' : 'brand'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{percentage}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden mb-1">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${Math.min(percentage, 100)}%`, backgroundColor: barColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{usage.current.toLocaleString()} / {usage.limit.toLocaleString()}</span>
|
||||||
|
<span>{usage.remaining.toLocaleString()} left</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Usage Dashboard" description="Your complete usage overview" />
|
||||||
|
<PageHeader
|
||||||
|
title="Usage Dashboard"
|
||||||
|
description="Your complete usage overview"
|
||||||
|
badge={{ icon: <TrendingUpIcon className="w-4 h-4" />, color: 'blue' }}
|
||||||
|
/>
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="h-32 bg-gray-200 dark:bg-gray-800 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-64 bg-gray-200 dark:bg-gray-800 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Usage Dashboard" description="Your complete usage overview" />
|
||||||
|
<PageHeader
|
||||||
|
title="Usage Dashboard"
|
||||||
|
description="Your complete usage overview at a glance"
|
||||||
|
badge={{ icon: <TrendingUpIcon className="w-4 h-4" />, color: 'blue' }}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||||
|
{[7, 30, 90].map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setPeriod(value)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
period === value
|
||||||
|
? 'bg-white dark:bg-gray-700 text-brand-600 dark:text-brand-400 shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}d
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
tone="neutral"
|
||||||
|
onClick={loadAllData}
|
||||||
|
startIcon={<RefreshCwIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* SECTION 1: Credit Overview - Hero Stats */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Credit Card */}
|
||||||
|
<Card className="lg:col-span-2 p-6 bg-gradient-to-br from-brand-50 to-purple-50 dark:from-brand-900/20 dark:to-purple-900/20 border-0">
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">Credit Balance</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Your available credits for AI operations</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/account/plans">
|
||||||
|
<Button size="sm" variant="primary" tone="brand">
|
||||||
|
Buy Credits
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="text-4xl font-bold text-brand-600 dark:text-brand-400 mb-1">
|
||||||
|
{creditBalance?.credits.toLocaleString() || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">Available Now</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-4xl font-bold text-purple-600 dark:text-purple-400 mb-1">
|
||||||
|
{creditBalance?.credits_used_this_month.toLocaleString() || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">Used This Month</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-4xl font-bold text-gray-900 dark:text-white mb-1">
|
||||||
|
{creditBalance?.plan_credits_per_month.toLocaleString() || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">Monthly Allowance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Credit Usage Bar */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex justify-between text-sm mb-2">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Monthly Usage</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{creditPercentage}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-white/50 dark:bg-gray-800/50 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500 bg-gradient-to-r from-brand-500 to-purple-500"
|
||||||
|
style={{ width: `${Math.min(creditPercentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Plan Info Card */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||||
|
<ZapIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">{usageSummary?.plan_name || 'Your Plan'}</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Current subscription</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Billing Period</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{usageSummary?.period_start ? new Date(usageSummary.period_start).toLocaleDateString() : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Resets In</span>
|
||||||
|
<span className="font-medium text-brand-600 dark:text-brand-400">
|
||||||
|
{usageSummary?.days_until_reset || 0} days
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link to="/account/plans/upgrade">
|
||||||
|
<Button size="sm" variant="outline" tone="brand" className="w-full">
|
||||||
|
Upgrade Plan
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION 2: Your Limits */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Your Limits</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Track your plan resources</p>
|
||||||
|
</div>
|
||||||
|
{usageSummary?.days_until_reset !== undefined && (
|
||||||
|
<Badge variant="soft" tone="info">
|
||||||
|
<CalendarIcon className="w-3 h-3 mr-1" />
|
||||||
|
Resets in {usageSummary.days_until_reset} days
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<LimitCard
|
||||||
|
title="Sites"
|
||||||
|
icon={<GlobeIcon className="w-4 h-4" />}
|
||||||
|
usage={usageSummary?.hard_limits?.sites}
|
||||||
|
type="hard"
|
||||||
|
color="var(--color-brand-500)"
|
||||||
|
/>
|
||||||
|
<LimitCard
|
||||||
|
title="Team Members"
|
||||||
|
icon={<UsersIcon className="w-4 h-4" />}
|
||||||
|
usage={usageSummary?.hard_limits?.users}
|
||||||
|
type="hard"
|
||||||
|
color="var(--color-purple-500)"
|
||||||
|
/>
|
||||||
|
<LimitCard
|
||||||
|
title="Keywords"
|
||||||
|
icon={<TagIcon className="w-4 h-4" />}
|
||||||
|
usage={usageSummary?.hard_limits?.keywords}
|
||||||
|
type="hard"
|
||||||
|
color="var(--color-success-500)"
|
||||||
|
/>
|
||||||
|
<LimitCard
|
||||||
|
title="Keyword Research"
|
||||||
|
icon={<SearchIcon className="w-4 h-4" />}
|
||||||
|
usage={undefined}
|
||||||
|
type="monthly"
|
||||||
|
color="var(--color-warning-500)"
|
||||||
|
comingSoon={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* SECTION 3: Activity Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Timeline Chart */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||||
|
<TrendingUpIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">Credit Usage Over Time</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Last {period} days</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dailyData.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
options={timelineOptions}
|
||||||
|
series={[{ name: 'Credits Used', data: dailyData.map(d => Math.abs(d.usage)) }]}
|
||||||
|
type="area"
|
||||||
|
height={200}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUpIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">No activity in this period</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Credit Consumption - Pie + Table */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
|
<PieChartIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">Credit Consumption</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Last {period} days by operation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Donut Chart */}
|
||||||
|
<div>
|
||||||
|
{donutSeries.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
options={donutOptions}
|
||||||
|
series={donutSeries}
|
||||||
|
type="donut"
|
||||||
|
height={240}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<PieChartIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">No usage data yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consumption Table */}
|
||||||
|
<div className="overflow-auto max-h-64">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-white dark:bg-gray-900">
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="text-left py-1.5 font-medium text-gray-600 dark:text-gray-400">Operation</th>
|
||||||
|
<th className="text-right py-1.5 font-medium text-gray-600 dark:text-gray-400">Credits</th>
|
||||||
|
<th className="text-right py-1.5 font-medium text-gray-600 dark:text-gray-400">Output</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{consumptionEntries.length > 0 ? (
|
||||||
|
consumptionEntries.map(([opType, data], index) => (
|
||||||
|
<tr key={opType} className="border-b border-gray-100 dark:border-gray-800 last:border-0">
|
||||||
|
<td className="py-1.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-900 dark:text-white truncate">
|
||||||
|
{OPERATION_LABELS[opType] || opType.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right font-medium text-gray-900 dark:text-white">
|
||||||
|
{data.credits.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||||
|
{data.count} {OPERATION_UNITS[opType] || 'Items'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="py-4 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No consumption data
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION 4: Quick Link to Detailed Logs */}
|
||||||
|
<Card className="p-6 bg-gradient-to-r from-gray-50 to-brand-50 dark:from-gray-800 dark:to-brand-900/20 border-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||||
|
<FileTextIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Need More Details?</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
View complete history of all AI operations with filters, dates, and USD costs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link to="/account/usage/logs">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
tone="brand"
|
||||||
|
endIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
View Usage Logs
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* SECTION 5: Credit Costs Reference (Collapsible) */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<details className="group">
|
||||||
|
<summary className="flex items-center justify-between cursor-pointer list-none">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||||
|
<ZapIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">How Credits Work</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">See estimated costs for each operation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon className="w-5 h-5 text-gray-500 group-open:rotate-180 transition-transform" />
|
||||||
|
</summary>
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FileTextIcon className="w-4 h-4 text-brand-500" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">Content Writing</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">~1 credit per 100 words</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<ImageIcon className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">Image Creation</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">1-15 credits per image (by quality)</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<TagIcon className="w-4 h-4 text-success-500" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">Keyword Grouping</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">~10 credits per batch</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<ZapIcon className="w-4 h-4 text-warning-500" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">Content Ideas</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">~15 credits per cluster</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FileTextIcon className="w-4 h-4 text-cyan-500" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">Image Prompts</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">~2 credits per prompt</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg relative">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<SearchIcon className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="font-medium text-gray-500 dark:text-gray-400">Keyword Research</span>
|
||||||
|
<Badge variant="soft" tone="warning" size="sm">Soon</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-500">Uses monthly limit (not credits)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
419
frontend/src/pages/account/UsageLogsPage.tsx
Normal file
419
frontend/src/pages/account/UsageLogsPage.tsx
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
/**
|
||||||
|
* Usage Logs Page - Detailed AI Operation Logs
|
||||||
|
* Shows a filterable, paginated table of all credit usage
|
||||||
|
* Consistent layout with Planner/Writer table pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
ImageIcon,
|
||||||
|
TagIcon,
|
||||||
|
ZapIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
DollarSignIcon,
|
||||||
|
TrendingUpIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import Button from '../../components/ui/button/Button';
|
||||||
|
import SelectDropdown from '../../components/form/SelectDropdown';
|
||||||
|
import Input from '../../components/form/input/InputField';
|
||||||
|
import { Pagination } from '../../components/ui/pagination/Pagination';
|
||||||
|
import { getCreditUsage, type CreditUsageLog } from '../../services/billing.api';
|
||||||
|
|
||||||
|
// User-friendly operation names (no model/token details)
|
||||||
|
const OPERATION_LABELS: Record<string, string> = {
|
||||||
|
content_generation: 'Content Writing',
|
||||||
|
image_generation: 'Image Creation',
|
||||||
|
image_prompt_extraction: 'Image Prompts',
|
||||||
|
keyword_clustering: 'Keyword Clustering',
|
||||||
|
clustering: 'Keyword Clustering',
|
||||||
|
idea_generation: 'Content Ideas',
|
||||||
|
content_analysis: 'Content Analysis',
|
||||||
|
linking: 'Internal Linking',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Operation icons
|
||||||
|
const OPERATION_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
content_generation: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||||
|
image_generation: <ImageIcon className="w-3.5 h-3.5" />,
|
||||||
|
image_prompt_extraction: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||||
|
keyword_clustering: <TagIcon className="w-3.5 h-3.5" />,
|
||||||
|
clustering: <TagIcon className="w-3.5 h-3.5" />,
|
||||||
|
idea_generation: <ZapIcon className="w-3.5 h-3.5" />,
|
||||||
|
content_analysis: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||||
|
linking: <TagIcon className="w-3.5 h-3.5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Operation type options for filter (only enabled operations)
|
||||||
|
const OPERATION_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Operations' },
|
||||||
|
{ value: 'content_generation', label: 'Content Writing' },
|
||||||
|
{ value: 'image_generation', label: 'Image Creation' },
|
||||||
|
{ value: 'image_prompt_extraction', label: 'Image Prompts' },
|
||||||
|
{ value: 'keyword_clustering', label: 'Keyword Clustering' },
|
||||||
|
{ value: 'idea_generation', label: 'Content Ideas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function UsageLogsPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
const [logs, setLogs] = useState<CreditUsageLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize] = useState(20);
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [operationFilter, setOperationFilter] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
// Calculate total pages
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
|
||||||
|
// Load usage logs
|
||||||
|
const loadLogs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params: any = {};
|
||||||
|
|
||||||
|
if (operationFilter) {
|
||||||
|
params.operation_type = operationFilter;
|
||||||
|
}
|
||||||
|
if (startDate) {
|
||||||
|
params.start_date = startDate;
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
params.end_date = endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pagination params
|
||||||
|
params.page = currentPage;
|
||||||
|
params.page_size = pageSize;
|
||||||
|
|
||||||
|
const data = await getCreditUsage(params);
|
||||||
|
setLogs(data.results || []);
|
||||||
|
setTotalCount(data.count || 0);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to load usage logs: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load on mount and when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs();
|
||||||
|
}, [currentPage, operationFilter, startDate, endDate]);
|
||||||
|
|
||||||
|
// Reset to page 1 when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [operationFilter, startDate, endDate]);
|
||||||
|
|
||||||
|
// Format date for display
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format cost in USD
|
||||||
|
const formatCost = (cost: string | null | undefined) => {
|
||||||
|
if (!cost) return '$0.00';
|
||||||
|
const num = parseFloat(cost);
|
||||||
|
if (isNaN(num)) return '$0.00';
|
||||||
|
return `$${num.toFixed(4)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get operation display info
|
||||||
|
const getOperationDisplay = (type: string) => {
|
||||||
|
return {
|
||||||
|
label: OPERATION_LABELS[type] || type.replace(/_/g, ' '),
|
||||||
|
icon: OPERATION_ICONS[type] || <ZapIcon className="w-3.5 h-3.5" />,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summary stats - calculate from all loaded logs
|
||||||
|
const summaryStats = useMemo(() => {
|
||||||
|
const totalCredits = logs.reduce((sum, log) => sum + log.credits_used, 0);
|
||||||
|
const totalCost = logs.reduce((sum, log) => sum + (parseFloat(log.cost_usd || '0') || 0), 0);
|
||||||
|
const avgCreditsPerOp = logs.length > 0 ? Math.round(totalCredits / logs.length) : 0;
|
||||||
|
|
||||||
|
// Count by operation type
|
||||||
|
const byOperation = logs.reduce((acc, log) => {
|
||||||
|
const op = log.operation_type;
|
||||||
|
acc[op] = (acc[op] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
const topOperation = Object.entries(byOperation).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
|
||||||
|
return { totalCredits, totalCost, avgCreditsPerOp, topOperation };
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
// Clear all filters
|
||||||
|
const clearFilters = () => {
|
||||||
|
setOperationFilter('');
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = operationFilter || startDate || endDate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Usage Logs" description="Detailed log of all AI operations" />
|
||||||
|
<PageHeader
|
||||||
|
title="Usage Logs"
|
||||||
|
description="Detailed history of all your AI operations and credit usage"
|
||||||
|
badge={{ icon: <FileTextIcon className="w-4 h-4" />, color: 'purple' }}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/account/usage">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
tone="neutral"
|
||||||
|
startIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
tone="neutral"
|
||||||
|
onClick={loadLogs}
|
||||||
|
startIcon={<RefreshCwIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Summary Cards - 5 metrics */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||||
|
<ZapIcon className="w-4 h-4 text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{summaryStats.totalCredits.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Credits Used
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||||
|
<DollarSignIcon className="w-4 h-4 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{formatCost(summaryStats.totalCost.toString())}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Total Cost
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
|
<CalendarIcon className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{totalCount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Operations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
|
||||||
|
<TrendingUpIcon className="w-4 h-4 text-warning-600 dark:text-warning-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{summaryStats.avgCreditsPerOp}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Avg/Operation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-info-100 dark:bg-info-900/30 rounded-lg">
|
||||||
|
<FileTextIcon className="w-4 h-4 text-info-600 dark:text-info-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white truncate">
|
||||||
|
{summaryStats.topOperation ? OPERATION_LABELS[summaryStats.topOperation[0]] || summaryStats.topOperation[0] : '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Top Operation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters - Inline style like Planner pages */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="w-44">
|
||||||
|
<SelectDropdown
|
||||||
|
options={OPERATION_OPTIONS}
|
||||||
|
value={operationFilter}
|
||||||
|
onChange={setOperationFilter}
|
||||||
|
placeholder="All Operations"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-36">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
placeholder="Start Date"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-36">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
placeholder="End Date"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table - Half width on large screens */}
|
||||||
|
<div className="lg:w-1/2">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="igny8-table-compact min-w-full w-full">
|
||||||
|
<thead className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Date</th>
|
||||||
|
<th className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Operation</th>
|
||||||
|
<th className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">Credits</th>
|
||||||
|
<th className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">Cost (USD)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||||
|
{loading ? (
|
||||||
|
// Loading skeleton
|
||||||
|
Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<tr key={i} className="igny8-skeleton-row">
|
||||||
|
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-28" /></td>
|
||||||
|
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-24" /></td>
|
||||||
|
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-12 mx-auto" /></td>
|
||||||
|
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-16 mx-auto" /></td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<FileTextIcon className="w-10 h-10 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-1">
|
||||||
|
No usage logs found
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{hasActiveFilters
|
||||||
|
? 'Try adjusting your filters to see more results.'
|
||||||
|
: 'Your AI operation history will appear here.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => {
|
||||||
|
const operationDisplay = getOperationDisplay(log.operation_type);
|
||||||
|
return (
|
||||||
|
<tr key={log.id} className="igny8-data-row">
|
||||||
|
<td className="px-5 py-2.5 text-gray-600 dark:text-gray-400">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
{operationDisplay.icon}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{operationDisplay.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-center font-medium text-gray-900 dark:text-white">
|
||||||
|
{log.credits_used.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-center text-gray-600 dark:text-gray-400">
|
||||||
|
{formatCost(log.cost_usd)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="px-5 py-3 border-t border-gray-100 dark:border-white/[0.05] flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
variant="icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -225,6 +225,8 @@ export async function getCreditUsage(params?: {
|
|||||||
operation_type?: string;
|
operation_type?: string;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
results: CreditUsageLog[];
|
results: CreditUsageLog[];
|
||||||
count: number;
|
count: number;
|
||||||
@@ -233,6 +235,8 @@ export async function getCreditUsage(params?: {
|
|||||||
if (params?.operation_type) queryParams.append('operation_type', params.operation_type);
|
if (params?.operation_type) queryParams.append('operation_type', params.operation_type);
|
||||||
if (params?.start_date) queryParams.append('start_date', params.start_date);
|
if (params?.start_date) queryParams.append('start_date', params.start_date);
|
||||||
if (params?.end_date) queryParams.append('end_date', params.end_date);
|
if (params?.end_date) queryParams.append('end_date', params.end_date);
|
||||||
|
if (params?.page) queryParams.append('page', params.page.toString());
|
||||||
|
if (params?.page_size) queryParams.append('page_size', params.page_size.toString());
|
||||||
|
|
||||||
const url = `/v1/billing/credits/usage/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
const url = `/v1/billing/credits/usage/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||||
return fetchAPI(url);
|
return fetchAPI(url);
|
||||||
@@ -905,17 +909,13 @@ export interface Plan {
|
|||||||
features?: string[];
|
features?: string[];
|
||||||
limits?: Record<string, any>;
|
limits?: Record<string, any>;
|
||||||
display_order?: number;
|
display_order?: number;
|
||||||
// Hard Limits
|
// Hard Limits (only 3 persistent limits)
|
||||||
max_sites?: number;
|
max_sites?: number;
|
||||||
max_users?: number;
|
max_users?: number;
|
||||||
max_keywords?: number;
|
max_keywords?: number;
|
||||||
max_clusters?: number;
|
// Monthly Limits (only ahrefs queries)
|
||||||
// Monthly Limits
|
max_ahrefs_queries?: number;
|
||||||
max_content_ideas?: number;
|
// Credits
|
||||||
max_content_words?: number;
|
|
||||||
max_images_basic?: number;
|
|
||||||
max_images_premium?: number;
|
|
||||||
max_image_prompts?: number;
|
|
||||||
included_credits?: number;
|
included_credits?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,18 +934,15 @@ export interface UsageSummary {
|
|||||||
period_start: string;
|
period_start: string;
|
||||||
period_end: string;
|
period_end: string;
|
||||||
days_until_reset: number;
|
days_until_reset: number;
|
||||||
|
// Simplified to only 3 hard limits
|
||||||
hard_limits: {
|
hard_limits: {
|
||||||
sites?: LimitUsage;
|
sites?: LimitUsage;
|
||||||
users?: LimitUsage;
|
users?: LimitUsage;
|
||||||
keywords?: LimitUsage;
|
keywords?: LimitUsage;
|
||||||
clusters?: LimitUsage;
|
|
||||||
};
|
};
|
||||||
|
// Simplified to only 1 monthly limit (Ahrefs queries)
|
||||||
monthly_limits: {
|
monthly_limits: {
|
||||||
content_ideas?: LimitUsage;
|
ahrefs_queries?: LimitUsage;
|
||||||
content_words?: LimitUsage;
|
|
||||||
images_basic?: LimitUsage;
|
|
||||||
images_premium?: LimitUsage;
|
|
||||||
image_prompts?: LimitUsage;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
frontend/src/utils/creditCheck.ts
Normal file
79
frontend/src/utils/creditCheck.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Credit Check Utilities
|
||||||
|
* Pre-flight credit checks for AI operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCreditBalance, type CreditBalance } from '../services/billing.api';
|
||||||
|
|
||||||
|
export interface CreditCheckResult {
|
||||||
|
hasEnoughCredits: boolean;
|
||||||
|
availableCredits: number;
|
||||||
|
requiredCredits: number;
|
||||||
|
shortfall: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account has enough credits for an operation
|
||||||
|
* @param estimatedCredits - Estimated credits needed for the operation
|
||||||
|
* @returns Credit check result with balance info
|
||||||
|
*/
|
||||||
|
export async function checkCreditsBeforeOperation(
|
||||||
|
estimatedCredits: number
|
||||||
|
): Promise<CreditCheckResult> {
|
||||||
|
try {
|
||||||
|
const balance: CreditBalance = await getCreditBalance();
|
||||||
|
const hasEnoughCredits = balance.credits >= estimatedCredits;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnoughCredits,
|
||||||
|
availableCredits: balance.credits,
|
||||||
|
requiredCredits: estimatedCredits,
|
||||||
|
shortfall: Math.max(0, estimatedCredits - balance.credits),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check credit balance:', error);
|
||||||
|
// Return pessimistic result on error to prevent operation
|
||||||
|
return {
|
||||||
|
hasEnoughCredits: false,
|
||||||
|
availableCredits: 0,
|
||||||
|
requiredCredits: estimatedCredits,
|
||||||
|
shortfall: estimatedCredits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimated credit costs for common operations
|
||||||
|
* These are estimates - actual costs depend on token usage
|
||||||
|
*/
|
||||||
|
export const ESTIMATED_CREDIT_COSTS = {
|
||||||
|
// Content Generation
|
||||||
|
content_generation_short: 5, // ~500 words
|
||||||
|
content_generation_medium: 10, // ~1000 words
|
||||||
|
content_generation_long: 20, // ~2000+ words
|
||||||
|
|
||||||
|
// Clustering & Planning
|
||||||
|
cluster_keywords: 3, // Per clustering operation
|
||||||
|
generate_content_ideas: 5, // Per batch of ideas
|
||||||
|
|
||||||
|
// Image Generation
|
||||||
|
image_basic: 2, // Basic quality
|
||||||
|
image_premium: 5, // Premium quality
|
||||||
|
|
||||||
|
// SEO Optimization
|
||||||
|
seo_analysis: 2,
|
||||||
|
seo_optimization: 3,
|
||||||
|
|
||||||
|
// Internal Linking
|
||||||
|
internal_linking: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get estimated cost for an operation type
|
||||||
|
*/
|
||||||
|
export function getEstimatedCost(
|
||||||
|
operationType: keyof typeof ESTIMATED_CREDIT_COSTS,
|
||||||
|
quantity: number = 1
|
||||||
|
): number {
|
||||||
|
return ESTIMATED_CREDIT_COSTS[operationType] * quantity;
|
||||||
|
}
|
||||||
@@ -16,12 +16,7 @@ export interface Plan {
|
|||||||
max_sites?: number;
|
max_sites?: number;
|
||||||
max_users?: number;
|
max_users?: number;
|
||||||
max_keywords?: number;
|
max_keywords?: number;
|
||||||
max_clusters?: number;
|
max_ahrefs_queries?: number;
|
||||||
max_content_ideas?: number;
|
|
||||||
max_content_words?: number;
|
|
||||||
max_images_basic?: number;
|
|
||||||
max_images_premium?: number;
|
|
||||||
max_image_prompts?: number;
|
|
||||||
included_credits?: number;
|
included_credits?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +32,8 @@ export const convertToPricingPlan = (plan: Plan): PricingPlan => {
|
|||||||
const features: string[] = [];
|
const features: string[] = [];
|
||||||
|
|
||||||
// Dynamic counts - shown with numbers from backend
|
// Dynamic counts - shown with numbers from backend
|
||||||
if (plan.max_content_ideas) {
|
if (plan.max_keywords) {
|
||||||
features.push(`**${formatNumber(plan.max_content_ideas)} Pages/Articles per month**`);
|
features.push(`**${formatNumber(plan.max_keywords)} Keywords**`);
|
||||||
}
|
}
|
||||||
if (plan.max_sites) {
|
if (plan.max_sites) {
|
||||||
features.push(`${plan.max_sites === 999999 ? 'Unlimited' : formatNumber(plan.max_sites)} Site${plan.max_sites > 1 && plan.max_sites !== 999999 ? 's' : ''}`);
|
features.push(`${plan.max_sites === 999999 ? 'Unlimited' : formatNumber(plan.max_sites)} Site${plan.max_sites > 1 && plan.max_sites !== 999999 ? 's' : ''}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user