Files
markitect-main/markitect/finance/cli.py
tegwick dab6b9fdef
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
feat: implement cost report template generator with Claude session tracking (issue #119)
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>
2025-10-04 01:31:36 +02:00

549 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)