Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Comprehensive cost tracking system implementation including: - Cost report generator with multiple formats (summary, detailed, audit) - Full CLI integration with cost management commands - Claude session cost tracking and estimation - Professional markdown reports with frontmatter/contentmatter - Automatic cost note generation for issue implementations - Complete test coverage (33 test cases) - Database integration with finance schema initialization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
549 lines
21 KiB
Python
549 lines
21 KiB
Python
"""
|
||
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) |