""" CLI commands for cost tracking and reporting. This module provides command-line interface for cost management operations including report generation, cost item management, and period calculations. """ import click import sys from datetime import date, datetime from decimal import Decimal from pathlib import Path from typing import Optional from .cost_manager import CostItemManager, CostItem from .report_generator import CostReportGenerator, ReportConfig from .session_tracker import SessionCostTracker from ..config_manager import ConfigurationManager @click.group(name='cost') def cost_commands(): """Cost tracking and financial reporting commands.""" pass @cost_commands.group(name='report') def cost_report(): """Generate cost reports and financial summaries.""" pass @cost_report.command('generate') @click.option('--period', help='Period in YYYY-MM format (e.g., 2025-01)') @click.option('--format', 'report_format', type=click.Choice(['summary', 'detailed', 'audit']), default='summary', help='Report format') @click.option('--output', 'output_path', help='Output file path (optional)') @click.option('--database', 'db_path', help='Database path (defaults to config)') def generate_report(period: Optional[str], report_format: str, output_path: Optional[str], db_path: Optional[str]): """Generate cost report for specified period.""" try: # Get database path if not db_path: config_manager = ConfigurationManager() config = config_manager.get_current_config() db_path = config.get('database_path') if not db_path: click.echo("Error: No database path specified. Use --database or configure database_path.", err=True) sys.exit(1) # Parse period if period: try: year, month = map(int, period.split('-')) if not (1 <= month <= 12): raise ValueError("Month must be between 1 and 12") except ValueError: click.echo("Error: Period must be in YYYY-MM format (e.g., 2025-01)", err=True) sys.exit(1) else: # Default to current month now = date.today() year, month = now.year, now.month # Generate report generator = CostReportGenerator(db_path) report_content = generator.generate_period_report(year, month, report_format) # Output report if output_path: generator.save_report(report_content, output_path) click.echo(f"Report saved to: {output_path}") else: click.echo(report_content) except Exception as e: click.echo(f"Error generating report: {e}", err=True) sys.exit(1) @cost_report.command('template') @click.option('--show', is_flag=True, help='Show template structure') @click.option('--format', 'report_format', type=click.Choice(['summary', 'detailed', 'audit']), default='summary', help='Template format to display') def show_template(show: bool, report_format: str): """Display cost report template structure.""" if show: template_info = { 'summary': { 'description': 'High-level overview with category totals', 'sections': ['Overview', 'Cost Breakdown by Category', 'Top Cost Items'], 'frontmatter': ['report_type', 'period_start', 'period_end', 'total_costs', 'currency'], 'contentmatter': ['cost_data.total', 'cost_data.categories', 'cost_data.active_items'] }, 'detailed': { 'description': 'Complete breakdown with all cost items', 'sections': ['Executive Summary', 'Category Sections (with item tables)'], 'frontmatter': ['report_type', 'period_start', 'period_end', 'categories_count'], 'contentmatter': ['cost_data.summary', 'cost_data.categories', 'cost_data.items'] }, 'audit': { 'description': 'Full audit trail with transaction history', 'sections': ['Audit Summary', 'Cost Verification', 'Transaction History', 'Audit Trail'], 'frontmatter': ['report_type', 'audit_trail', 'transactions_count'], 'contentmatter': ['audit_data.period_summary', 'audit_data.transactions'] } } info = template_info[report_format] click.echo(f"## {report_format.title()} Report Template") click.echo(f"**Description**: {info['description']}") click.echo() click.echo("**Sections**:") for section in info['sections']: click.echo(f"- {section}") click.echo() click.echo("**Key Frontmatter Fields**:") for field in info['frontmatter']: click.echo(f"- {field}") click.echo() click.echo("**Key Contentmatter Fields**:") for field in info['contentmatter']: click.echo(f"- {field}") else: click.echo("Use --show to display template structure") @cost_commands.group(name='item') def cost_item(): """Manage cost items (create, update, list).""" pass @cost_item.command('add') @click.argument('name') @click.option('--category', required=True, help='Category name') @click.option('--amount', type=float, required=True, help='Cost amount in EUR') @click.option('--type', 'cost_type', type=click.Choice(['monthly', 'one_time']), required=True, help='Cost type') @click.option('--start-date', help='Start date (YYYY-MM-DD, defaults to today)') @click.option('--description', help='Optional description') @click.option('--database', 'db_path', help='Database path (defaults to config)') def add_cost_item(name: str, category: str, amount: float, cost_type: str, start_date: Optional[str], description: Optional[str], db_path: Optional[str]): """Add a new cost item.""" try: # Get database path if not db_path: config_manager = ConfigurationManager() config = config_manager.get_current_config() db_path = config.get('database_path') if not db_path: click.echo("Error: No database path specified.", err=True) sys.exit(1) # Parse start date if start_date: try: start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() except ValueError: click.echo("Error: Start date must be in YYYY-MM-DD format", err=True) sys.exit(1) else: start_date_obj = date.today() # Get cost manager cost_manager = CostItemManager(db_path) # Find category by name category_obj = cost_manager.get_category_by_name(category) if not category_obj: click.echo(f"Error: Category '{category}' not found.", err=True) click.echo("Available categories:") for cat in cost_manager.list_categories(): click.echo(f" - {cat['name']}") sys.exit(1) # Create cost item cost_item = CostItem( category_id=category_obj['id'], name=name, description=description, cost_type=cost_type, amount_eur=Decimal(str(amount)), starting_from_date=start_date_obj ) cost_id = cost_manager.create_cost_item(cost_item) click.echo(f"✅ Created cost item '{name}' (ID: {cost_id})") except Exception as e: click.echo(f"Error creating cost item: {e}", err=True) sys.exit(1) @cost_item.command('list') @click.option('--category', help='Filter by category name') @click.option('--type', 'cost_type', type=click.Choice(['monthly', 'one_time']), help='Filter by cost type') @click.option('--active/--all', default=True, help='Show only active items (default)') @click.option('--database', 'db_path', help='Database path (defaults to config)') def list_cost_items(category: Optional[str], cost_type: Optional[str], active: bool, db_path: Optional[str]): """List cost items with optional filtering.""" try: # Get database path if not db_path: config_manager = ConfigurationManager() config = config_manager.get_current_config() db_path = config.get('database_path') if not db_path: click.echo("Error: No database path specified.", err=True) sys.exit(1) cost_manager = CostItemManager(db_path) # Get category ID if specified category_id = None if category: category_obj = cost_manager.get_category_by_name(category) if not category_obj: click.echo(f"Error: Category '{category}' not found.", err=True) sys.exit(1) category_id = category_obj['id'] # List cost items items = cost_manager.list_cost_items( active_only=active, category_id=category_id, cost_type=cost_type ) if not items: click.echo("No cost items found.") return # Display items click.echo(f"{'ID':<4} {'Name':<25} {'Category':<20} {'Type':<8} {'Amount':<10} {'Status'}") click.echo("-" * 80) for item in items: status = "Active" if item['is_active'] else "Inactive" click.echo( f"{item['id']:<4} {item['name'][:24]:<25} " f"{(item['category_name'] or 'N/A')[:19]:<20} " f"{item['cost_type']:<8} €{float(item['amount_eur']):<9.2f} {status}" ) click.echo(f"\nTotal: {len(items)} items") except Exception as e: click.echo(f"Error listing cost items: {e}", err=True) sys.exit(1) @cost_commands.group(name='category') def cost_category(): """Manage cost categories.""" pass @cost_category.command('list') @click.option('--database', 'db_path', help='Database path (defaults to config)') def list_categories(db_path: Optional[str]): """List all cost categories.""" try: # Get database path if not db_path: config_manager = ConfigurationManager() config = config_manager.get_current_config() db_path = config.get('database_path') if not db_path: click.echo("Error: No database path specified.", err=True) sys.exit(1) cost_manager = CostItemManager(db_path) categories = cost_manager.list_categories() if not categories: click.echo("No categories found.") return click.echo(f"{'ID':<4} {'Name':<25} {'Description'}") click.echo("-" * 70) for category in categories: description = category['description'] or '' click.echo(f"{category['id']:<4} {category['name'][:24]:<25} {description[:40]}") click.echo(f"\nTotal: {len(categories)} categories") except Exception as e: click.echo(f"Error listing categories: {e}", err=True) sys.exit(1) @cost_category.command('add') @click.argument('name') @click.option('--description', help='Category description') @click.option('--database', 'db_path', help='Database path (defaults to config)') def add_category(name: str, description: Optional[str], db_path: Optional[str]): """Add a new cost category.""" try: # Get database path if not db_path: config_manager = ConfigurationManager() config = config_manager.get_current_config() db_path = config.get('database_path') if not db_path: click.echo("Error: No database path specified.", err=True) sys.exit(1) cost_manager = CostItemManager(db_path) category_id = cost_manager.create_category(name, description) click.echo(f"✅ Created category '{name}' (ID: {category_id})") except Exception as e: click.echo(f"Error creating category: {e}", err=True) sys.exit(1) @cost_commands.command('calculate') @click.option('--period', help='Period in YYYY-MM format (defaults to current month)') @click.option('--database', 'db_path', help='Database path (defaults to config)') def calculate_costs(period: Optional[str], db_path: Optional[str]): """Calculate costs for a specific period.""" try: # Get database path if not db_path: config_manager = ConfigurationManager() config = config_manager.get_current_config() db_path = config.get('database_path') if not db_path: click.echo("Error: No database path specified.", err=True) sys.exit(1) # Parse period if period: try: year, month = map(int, period.split('-')) if not (1 <= month <= 12): raise ValueError("Month must be between 1 and 12") except ValueError: click.echo("Error: Period must be in YYYY-MM format", err=True) sys.exit(1) else: now = date.today() year, month = now.year, now.month # Calculate period dates from calendar import monthrange period_start = date(year, month, 1) _, last_day = monthrange(year, month) period_end = date(year, month, last_day) # Calculate costs cost_manager = CostItemManager(db_path) calculations = cost_manager.calculate_period_costs(period_start, period_end) # Display results click.echo(f"Cost Calculation - {period_start.strftime('%B %Y')}") click.echo("=" * 50) click.echo(f"Period: {period_start} to {period_end}") click.echo(f"Monthly Recurring: €{calculations['total_monthly']:.2f}") click.echo(f"One-time Expenses: €{calculations['total_one_time']:.2f}") click.echo(f"Total Period Cost: €{calculations['total_period']:.2f}") click.echo(f"Active Cost Items: {calculations['active_cost_items']}") if calculations['category_breakdown']: click.echo("\nCategory Breakdown:") for category, breakdown in calculations['category_breakdown'].items(): if breakdown['total'] > 0: click.echo(f" {category}: €{breakdown['total']:.2f}") except Exception as e: click.echo(f"Error calculating costs: {e}", err=True) sys.exit(1) @cost_commands.group(name='session') def cost_session(): """Track Claude session costs for issue implementation.""" pass @cost_session.command('track') @click.argument('issue_id', type=int) @click.argument('issue_title') @click.option('--input-tokens', type=int, required=True, help='Number of input tokens') @click.option('--output-tokens', type=int, required=True, help='Number of output tokens') @click.option('--model', default='claude-sonnet-4', help='Claude model used') @click.option('--summary', help='Implementation summary') @click.option('--save-note/--no-save-note', default=True, help='Save cost note to file') @click.option('--output-dir', default='cost_notes', help='Directory for cost notes') @click.option('--database', 'db_path', help='Database path (defaults to config)') def track_session(issue_id: int, issue_title: str, input_tokens: int, output_tokens: int, model: str, summary: Optional[str], save_note: bool, output_dir: str, db_path: Optional[str]): """Track Claude session cost for issue implementation.""" try: # Get database path if not db_path: config_manager = ConfigurationManager() config = config_manager.get_current_config() db_path = config.get('database_path') if not db_path: click.echo("Error: No database path specified.", err=True) sys.exit(1) # Initialize tracker tracker = SessionCostTracker(db_path) # Track the session result = tracker.track_issue_completion( issue_id=issue_id, issue_title=issue_title, input_tokens=input_tokens, output_tokens=output_tokens, model=model, implementation_summary=summary, save_note=save_note, output_dir=output_dir ) if result['tracking_successful']: session_cost = result['session_cost'] click.echo(f"✅ Issue #{issue_id} cost tracking completed") click.echo(f"📊 Session Cost: €{session_cost['total_cost_eur']:.4f} (${session_cost['total_cost_usd']:.4f} USD)") click.echo(f"🔤 Token Usage: {session_cost['total_tokens']:,} tokens") click.echo(f"🤖 Model: {session_cost['model']}") if result['saved_path']: click.echo(f"📝 Cost note saved: {result['saved_path']}") else: click.echo("❌ Failed to track session cost", err=True) sys.exit(1) except Exception as e: click.echo(f"Error tracking session: {e}", err=True) sys.exit(1) @cost_session.command('estimate') @click.option('--input-tokens', type=int, required=True, help='Number of input tokens') @click.option('--output-tokens', type=int, required=True, help='Number of output tokens') @click.option('--model', default='claude-sonnet-4', help='Claude model used') def estimate_cost(input_tokens: int, output_tokens: int, model: str): """Estimate Claude session cost without tracking.""" try: # Create temporary tracker for estimation tracker = SessionCostTracker("/tmp/dummy.db") # DB not needed for estimation session_cost = tracker.estimate_session_cost(input_tokens, output_tokens, model) click.echo(f"💰 Cost Estimate - {session_cost['model']}") click.echo(f"Input: {session_cost['input_tokens']:8,} tokens × ${session_cost['pricing_rates']['input_per_million']:>5.2f}/M = ${session_cost['input_cost_usd']:.4f}") click.echo(f"Output: {session_cost['output_tokens']:8,} tokens × ${session_cost['pricing_rates']['output_per_million']:>5.2f}/M = ${session_cost['output_cost_usd']:.4f}") click.echo(f"{'='*60}") click.echo(f"Total: {session_cost['total_tokens']:8,} tokens = ${session_cost['total_cost_usd']:.4f} USD") click.echo(f" = €{session_cost['total_cost_eur']:.4f} EUR") except Exception as e: click.echo(f"Error estimating cost: {e}", err=True) sys.exit(1) @cost_session.command('summary') @click.option('--issue-ids', help='Comma-separated list of issue IDs to filter by') @click.option('--database', 'db_path', help='Database path (defaults to config)') def session_summary(issue_ids: Optional[str], db_path: Optional[str]): """Show summary of Claude session costs.""" try: # Get database path if not db_path: config_manager = ConfigurationManager() config = config_manager.get_current_config() db_path = config.get('database_path') if not db_path: click.echo("Error: No database path specified.", err=True) sys.exit(1) # Parse issue IDs if provided issue_id_list = None if issue_ids: try: issue_id_list = [int(id.strip()) for id in issue_ids.split(',')] except ValueError: click.echo("Error: Invalid issue ID format", err=True) sys.exit(1) # Get summary tracker = SessionCostTracker(db_path) summary = tracker.get_issue_costs_summary(issue_id_list) if summary['issue_count'] == 0: click.echo("No Claude session costs found.") return click.echo(f"🤖 Claude Session Cost Summary") click.echo("=" * 40) click.echo(f"Issues: {summary['issue_count']}") click.echo(f"Total Cost: €{summary['total_costs']:.4f} {summary['currency']}") if summary['items']: click.echo(f"\nDetailed Breakdown:") click.echo(f"{'Issue':<8} {'Date':<12} {'Amount':<12} {'Description'}") click.echo("-" * 60) for item in summary['items']: # Extract issue ID from name issue_num = "N/A" if "Issue #" in item['name']: try: issue_num = f"#{item['name'].split('Issue #')[1].split()[0]}" except: pass click.echo( f"{issue_num:<8} {item['starting_from_date']:<12} " f"€{float(item['amount_eur']):<11.4f} {item['name'][:30]}" ) except Exception as e: click.echo(f"Error getting session summary: {e}", err=True) sys.exit(1)