Files
markitect-main/markitect/finance/cli.py
tegwick 397b607442 feat: implement comprehensive Period Management Framework (issue #112)
Complete period lifecycle management system including:

- PeriodManager class with full lifecycle operations
- Period status management (open/calculating/closed) with validation
- Period overlap detection and conflict resolution
- Comprehensive cost calculation and aggregation engine
- Loss carried forward calculations between periods
- Period closure validation with audit trails
- Current period detection and auto-creation utilities

CLI Integration:
- Complete period command suite (create, list, show, calculate, status, close, current)
- Professional CLI output with detailed formatting
- Comprehensive error handling and validation
- Date filtering and status filtering capabilities

Testing:
- 25 core PeriodManager tests covering all functionality
- 24 CLI command tests ensuring proper integration
- Edge case testing for complex scenarios
- 49 total tests passing with comprehensive coverage

Database Integration:
- Utilizes existing cost_periods schema from FinanceModels
- Full SQLite integration with proper constraints
- Performance-optimized indexes and queries
- Seamless integration with existing cost tracking system

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 01:41:58 +02:00

911 lines
34 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, timedelta
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 .period_manager import PeriodManager, PeriodStatus
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)
@cost_commands.group(name='period')
def cost_period():
"""Manage calculation periods and lifecycle."""
pass
@cost_period.command('create')
@click.option('--start-date', required=True, help='Period start date (YYYY-MM-DD)')
@click.option('--end-date', required=True, help='Period end date (YYYY-MM-DD)')
@click.option('--type', 'period_type', default='monthly', help='Period type (monthly, quarterly, yearly)')
@click.option('--loss-forward', type=float, help='Loss carried forward from previous period')
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def create_period(start_date: str, end_date: str, period_type: str,
loss_forward: Optional[float], db_path: Optional[str]):
"""Create a new calculation 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 dates
try:
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError:
click.echo("Error: Dates must be in YYYY-MM-DD format", err=True)
sys.exit(1)
# Initialize period manager
period_manager = PeriodManager(db_path)
# Convert loss forward to Decimal
loss_forward_decimal = None
if loss_forward is not None:
loss_forward_decimal = Decimal(str(loss_forward))
# Create period
period_id = period_manager.create_period(
period_start=start_date_obj,
period_end=end_date_obj,
period_type=period_type,
loss_carried_forward=loss_forward_decimal
)
if period_id:
click.echo(f"✅ Created period #{period_id}")
click.echo(f"📅 Period: {start_date} to {end_date}")
click.echo(f"📊 Type: {period_type}")
if loss_forward:
click.echo(f"💸 Loss carried forward: €{loss_forward:.4f}")
else:
click.echo("❌ Failed to create period", err=True)
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error creating period: {e}", err=True)
sys.exit(1)
@cost_period.command('list')
@click.option('--status', type=click.Choice(['open', 'calculating', 'closed']),
help='Filter by status')
@click.option('--start-from', help='Show periods starting from date (YYYY-MM-DD)')
@click.option('--end-before', help='Show periods ending before date (YYYY-MM-DD)')
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def list_periods(status: Optional[str], start_from: Optional[str],
end_before: Optional[str], db_path: Optional[str]):
"""List calculation periods 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)
# Parse date filters
start_date_obj = None
end_date_obj = None
if start_from:
try:
start_date_obj = datetime.strptime(start_from, '%Y-%m-%d').date()
except ValueError:
click.echo("Error: Start date must be in YYYY-MM-DD format", err=True)
sys.exit(1)
if end_before:
try:
end_date_obj = datetime.strptime(end_before, '%Y-%m-%d').date()
except ValueError:
click.echo("Error: End date must be in YYYY-MM-DD format", err=True)
sys.exit(1)
# Get periods
period_manager = PeriodManager(db_path)
periods = period_manager.list_periods(
status_filter=status,
start_date=start_date_obj,
end_date=end_date_obj
)
if not periods:
click.echo("No periods found matching criteria.")
return
# Display periods
click.echo(f"📅 Calculation Periods")
click.echo("=" * 80)
click.echo(f"{'ID':<4} {'Start Date':<12} {'End Date':<12} {'Type':<10} {'Status':<12} {'Total Cost':<12}")
click.echo("-" * 80)
for period in periods:
click.echo(
f"{period['id']:<4} {period['period_start']:<12} {period['period_end']:<12} "
f"{period['period_type']:<10} {period['status']:<12}{float(period['total_costs']):<11.2f}"
)
click.echo(f"\nTotal: {len(periods)} periods")
except Exception as e:
click.echo(f"Error listing periods: {e}", err=True)
sys.exit(1)
@cost_period.command('show')
@click.argument('period_id', type=int)
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def show_period(period_id: int, db_path: Optional[str]):
"""Show detailed information 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)
# Get period details
period_manager = PeriodManager(db_path)
period = period_manager.get_period_by_id(period_id)
if not period:
click.echo(f"Period #{period_id} not found.", err=True)
sys.exit(1)
# Display period details
click.echo(f"📅 Period #{period['id']} Details")
click.echo("=" * 40)
click.echo(f"Start Date: {period['period_start']}")
click.echo(f"End Date: {period['period_end']}")
click.echo(f"Type: {period['period_type']}")
click.echo(f"Status: {period['status']}")
click.echo(f"Total Costs: €{float(period['total_costs']):.4f}")
click.echo(f"Active Issues: {period['active_issues_count']}")
if period['cost_per_issue'] > 0:
click.echo(f"Cost per Issue: €{float(period['cost_per_issue']):.4f}")
if period['loss_carried_forward'] > 0:
click.echo(f"Loss Carried Forward: €{float(period['loss_carried_forward']):.4f}")
click.echo(f"Created: {period['created_at']}")
if period['updated_at']:
click.echo(f"Updated: {period['updated_at']}")
except Exception as e:
click.echo(f"Error showing period: {e}", err=True)
sys.exit(1)
@cost_period.command('calculate')
@click.argument('period_id', type=int)
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def calculate_period(period_id: int, 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)
# Calculate period costs
period_manager = PeriodManager(db_path)
calculation = period_manager.calculate_period_costs(period_id)
click.echo(f"📊 Period #{calculation['period_id']} Cost Calculation")
click.echo("=" * 50)
click.echo(f"Period: {calculation['period_start']} to {calculation['period_end']}")
click.echo(f"Monthly Recurring: €{calculation['monthly_costs']:.2f}")
click.echo(f"One-time Expenses: €{calculation['one_time_costs']:.2f}")
if calculation['loss_carried_forward'] > 0:
click.echo(f"Loss Carried Forward: €{calculation['loss_carried_forward']:.2f}")
click.echo(f"Total Period Cost: €{calculation['total_costs']:.2f}")
click.echo(f"Calculated: {calculation['calculation_date'][:19]}")
except Exception as e:
click.echo(f"Error calculating period: {e}", err=True)
sys.exit(1)
@cost_period.command('status')
@click.argument('period_id', type=int)
@click.argument('new_status', type=click.Choice(['open', 'calculating', 'closed']))
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def update_status(period_id: int, new_status: str, db_path: Optional[str]):
"""Update period status."""
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)
# Update status
period_manager = PeriodManager(db_path)
success = period_manager.update_period_status(period_id, new_status)
if success:
click.echo(f"✅ Period #{period_id} status updated to '{new_status}'")
else:
click.echo(f"❌ Failed to update period #{period_id} status", err=True)
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error updating status: {e}", err=True)
sys.exit(1)
@cost_period.command('close')
@click.argument('period_id', type=int)
@click.option('--skip-validation', is_flag=True, help='Skip transaction validation')
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def close_period(period_id: int, skip_validation: bool, db_path: Optional[str]):
"""Close a period after final calculations."""
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)
# Close period
period_manager = PeriodManager(db_path)
success = period_manager.close_period(
period_id,
validate_transactions=not skip_validation
)
if success:
click.echo(f"✅ Period #{period_id} has been closed")
# Show final calculation
period = period_manager.get_period_by_id(period_id)
if period:
click.echo(f"💰 Final total cost: €{float(period['total_costs']):.4f}")
else:
click.echo(f"❌ Failed to close period #{period_id}", err=True)
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error closing period: {e}", err=True)
sys.exit(1)
@cost_period.command('current')
@click.option('--date', 'date_str', help='Date to find period for (YYYY-MM-DD, defaults to today)')
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def current_period(date_str: Optional[str], db_path: Optional[str]):
"""Show current active period for a given date."""
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 date if provided
target_date = None
if date_str:
try:
target_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
click.echo("Error: Date must be in YYYY-MM-DD format", err=True)
sys.exit(1)
# Get current period
period_manager = PeriodManager(db_path)
current = period_manager.get_current_period(target_date)
if not current:
date_display = date_str if date_str else "today"
click.echo(f"No active period found for {date_display}")
# Suggest creating a period
if not date_str:
today = date.today()
# Calculate last day of current month
if today.month == 12:
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
last_day = (next_month - timedelta(days=1)).day
click.echo(f"💡 Create one with: markitect cost period create --start-date {today.year}-{today.month:02d}-01 --end-date {today.year}-{today.month:02d}-{last_day:02d}")
return
# Display current period
click.echo(f"📅 Current Active Period")
click.echo("=" * 30)
click.echo(f"Period #{current['id']}")
click.echo(f"Dates: {current['period_start']} to {current['period_end']}")
click.echo(f"Status: {current['status']}")
click.echo(f"Total Costs: €{float(current['total_costs']):.4f}")
except Exception as e:
click.echo(f"Error getting current period: {e}", err=True)
sys.exit(1)