Phase 3 - credts, usage, plans app pages #Migrations

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-06 21:28:13 +00:00
parent cb8e747387
commit 9ca048fb9d
37 changed files with 9328 additions and 1149 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
description = models.CharField()
created_at = models.DateTimeField()
```
credit_type = models.CharField() # idea/content/image/optimization ### CreditUsageLog (Analytics)
amount = models.IntegerField()
operation = models.CharField() # generate_content, etc.
created_at = models.DateTimeField(auto_now_add=True) ```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"""
```
def add_credits(account, credit_type, amount, reason) -> None: ### LimitService
"""Add credits (admin/purchase)"""
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"""
@classmethod
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({
throw error; requiredCredits: check.requiredCredits,
availableCredits: check.availableCredits,
});
return;
} }
// 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -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" },
], ],
}, },
{ {

View File

@@ -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[];
} }

View File

@@ -113,7 +113,6 @@ export default function SeedKeywords() {
</table> </table>
</div> </div>
</Card> </Card>
)}
</> </>
); );
} }

View File

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

View File

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

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

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

View File

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

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

View File

@@ -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' : ''}`);