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>
553 lines
17 KiB
Python
553 lines
17 KiB
Python
"""
|
|
Cost Item Management System for MarkiTect.
|
|
|
|
This module provides comprehensive cost item lifecycle management including:
|
|
- Cost item creation, modification, and lifecycle management
|
|
- Category management and validation
|
|
- Business rule enforcement and validation
|
|
- Integration with period management for cost calculations
|
|
|
|
The system supports both monthly recurring costs and one-time expenses
|
|
with proper validation and audit trails.
|
|
"""
|
|
|
|
import sqlite3
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Optional, List, Dict, Any, Union
|
|
from dataclasses import dataclass
|
|
|
|
from .models import FinanceModels
|
|
|
|
|
|
@dataclass
|
|
class CostItem:
|
|
"""Data class representing a cost item."""
|
|
id: Optional[int] = None
|
|
category_id: int = None
|
|
name: str = ""
|
|
description: Optional[str] = None
|
|
cost_type: str = "" # 'monthly' or 'one_time'
|
|
amount_eur: Decimal = Decimal('0.00')
|
|
currency: str = "EUR"
|
|
starting_from_date: date = None
|
|
ending_date: Optional[date] = None
|
|
is_active: bool = True
|
|
created_at: Optional[datetime] = None
|
|
updated_at: Optional[datetime] = None
|
|
|
|
|
|
@dataclass
|
|
class CostCategory:
|
|
"""Data class representing a cost category."""
|
|
id: Optional[int] = None
|
|
name: str = ""
|
|
description: Optional[str] = None
|
|
created_at: Optional[datetime] = None
|
|
updated_at: Optional[datetime] = None
|
|
|
|
|
|
class CostItemManager:
|
|
"""Manager for cost item operations and business logic."""
|
|
|
|
def __init__(self, db_path: str):
|
|
"""
|
|
Initialize cost item manager.
|
|
|
|
Args:
|
|
db_path: Path to SQLite database file
|
|
"""
|
|
self.db_path = db_path
|
|
self.finance_models = FinanceModels(db_path)
|
|
|
|
def create_cost_item(self, cost_item: CostItem) -> Optional[int]:
|
|
"""
|
|
Create a new cost item with validation.
|
|
|
|
Args:
|
|
cost_item: CostItem instance to create
|
|
|
|
Returns:
|
|
ID of created cost item, or None if creation failed
|
|
|
|
Raises:
|
|
ValueError: If validation fails
|
|
sqlite3.Error: If database operation fails
|
|
"""
|
|
# Validate cost item
|
|
self._validate_cost_item(cost_item)
|
|
|
|
conn = self.finance_models.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
cursor.execute('''
|
|
INSERT INTO cost_items
|
|
(category_id, name, description, cost_type, amount_eur, currency,
|
|
starting_from_date, ending_date, is_active, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''', (
|
|
cost_item.category_id,
|
|
cost_item.name,
|
|
cost_item.description,
|
|
cost_item.cost_type,
|
|
float(cost_item.amount_eur),
|
|
cost_item.currency,
|
|
cost_item.starting_from_date.isoformat() if cost_item.starting_from_date else None,
|
|
cost_item.ending_date.isoformat() if cost_item.ending_date else None,
|
|
cost_item.is_active,
|
|
datetime.now().isoformat(),
|
|
datetime.now().isoformat()
|
|
))
|
|
|
|
cost_item_id = cursor.lastrowid
|
|
conn.commit()
|
|
|
|
return cost_item_id
|
|
|
|
except sqlite3.Error as e:
|
|
conn.rollback()
|
|
raise
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
def update_cost_item(self, cost_item_id: int, updates: Dict[str, Any]) -> bool:
|
|
"""
|
|
Update an existing cost item.
|
|
|
|
Args:
|
|
cost_item_id: ID of cost item to update
|
|
updates: Dictionary of field updates
|
|
|
|
Returns:
|
|
True if update was successful, False otherwise
|
|
|
|
Raises:
|
|
ValueError: If validation fails
|
|
sqlite3.Error: If database operation fails
|
|
"""
|
|
# Get existing cost item for validation
|
|
existing_item = self.get_cost_item(cost_item_id)
|
|
if not existing_item:
|
|
raise ValueError(f"Cost item with ID {cost_item_id} not found")
|
|
|
|
# Apply updates to existing item for validation
|
|
# Filter out extra fields that aren't part of CostItem dataclass
|
|
cost_item_fields = {
|
|
'id', 'category_id', 'name', 'description', 'cost_type',
|
|
'amount_eur', 'currency', 'starting_from_date', 'ending_date',
|
|
'is_active', 'created_at', 'updated_at'
|
|
}
|
|
filtered_item = {k: v for k, v in existing_item.items() if k in cost_item_fields}
|
|
|
|
# Convert date strings back to date objects for validation
|
|
if filtered_item.get('starting_from_date'):
|
|
filtered_item['starting_from_date'] = date.fromisoformat(filtered_item['starting_from_date'])
|
|
if filtered_item.get('ending_date'):
|
|
filtered_item['ending_date'] = date.fromisoformat(filtered_item['ending_date'])
|
|
if filtered_item.get('amount_eur'):
|
|
filtered_item['amount_eur'] = Decimal(str(filtered_item['amount_eur']))
|
|
|
|
updated_item = CostItem(**filtered_item)
|
|
for field, value in updates.items():
|
|
if hasattr(updated_item, field):
|
|
setattr(updated_item, field, value)
|
|
else:
|
|
raise ValueError(f"Invalid field: {field}")
|
|
|
|
# Validate updated item
|
|
self._validate_cost_item(updated_item)
|
|
|
|
# Build dynamic update query
|
|
set_clauses = []
|
|
values = []
|
|
|
|
for field, value in updates.items():
|
|
if field in ['amount_eur'] and isinstance(value, Decimal):
|
|
value = float(value)
|
|
elif field in ['starting_from_date', 'ending_date'] and isinstance(value, date):
|
|
value = value.isoformat() if value else None
|
|
|
|
set_clauses.append(f"{field} = ?")
|
|
values.append(value)
|
|
|
|
# Add updated_at timestamp
|
|
set_clauses.append("updated_at = ?")
|
|
values.append(datetime.now().isoformat())
|
|
values.append(cost_item_id)
|
|
|
|
conn = self.finance_models.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
cursor.execute(f'''
|
|
UPDATE cost_items
|
|
SET {', '.join(set_clauses)}
|
|
WHERE id = ?
|
|
''', values)
|
|
|
|
success = cursor.rowcount > 0
|
|
conn.commit()
|
|
|
|
return success
|
|
|
|
except sqlite3.Error as e:
|
|
conn.rollback()
|
|
raise
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
def deactivate_cost_item(self, cost_item_id: int, ending_date: Optional[date] = None) -> bool:
|
|
"""
|
|
Deactivate a cost item (soft delete).
|
|
|
|
Args:
|
|
cost_item_id: ID of cost item to deactivate
|
|
ending_date: Optional ending date (defaults to today)
|
|
|
|
Returns:
|
|
True if deactivation was successful, False otherwise
|
|
"""
|
|
if ending_date is None:
|
|
ending_date = date.today()
|
|
|
|
updates = {
|
|
'is_active': False,
|
|
'ending_date': ending_date
|
|
}
|
|
|
|
return self.update_cost_item(cost_item_id, updates)
|
|
|
|
def get_cost_item(self, cost_item_id: int) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Retrieve a cost item by ID.
|
|
|
|
Args:
|
|
cost_item_id: ID of cost item to retrieve
|
|
|
|
Returns:
|
|
Dictionary containing cost item data, or None if not found
|
|
"""
|
|
conn = self.finance_models.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
cursor.execute('''
|
|
SELECT ci.*, cc.name as category_name
|
|
FROM cost_items ci
|
|
LEFT JOIN cost_categories cc ON ci.category_id = cc.id
|
|
WHERE ci.id = ?
|
|
''', (cost_item_id,))
|
|
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
|
|
# Convert row to dictionary
|
|
columns = [desc[0] for desc in cursor.description]
|
|
return dict(zip(columns, row))
|
|
|
|
except sqlite3.Error as e:
|
|
return None
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
def list_cost_items(self,
|
|
active_only: bool = True,
|
|
category_id: Optional[int] = None,
|
|
cost_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
"""
|
|
List cost items with optional filtering.
|
|
|
|
Args:
|
|
active_only: If True, only return active cost items
|
|
category_id: Optional category filter
|
|
cost_type: Optional cost type filter ('monthly' or 'one_time')
|
|
|
|
Returns:
|
|
List of cost item dictionaries
|
|
"""
|
|
conn = self.finance_models.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Build WHERE clause
|
|
conditions = []
|
|
values = []
|
|
|
|
if active_only:
|
|
conditions.append("ci.is_active = ?")
|
|
values.append(True)
|
|
|
|
if category_id is not None:
|
|
conditions.append("ci.category_id = ?")
|
|
values.append(category_id)
|
|
|
|
if cost_type is not None:
|
|
conditions.append("ci.cost_type = ?")
|
|
values.append(cost_type)
|
|
|
|
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
|
|
|
|
try:
|
|
cursor.execute(f'''
|
|
SELECT ci.*, cc.name as category_name
|
|
FROM cost_items ci
|
|
LEFT JOIN cost_categories cc ON ci.category_id = cc.id
|
|
{where_clause}
|
|
ORDER BY ci.category_id, ci.name
|
|
''', values)
|
|
|
|
rows = cursor.fetchall()
|
|
columns = [desc[0] for desc in cursor.description]
|
|
|
|
return [dict(zip(columns, row)) for row in rows]
|
|
|
|
except sqlite3.Error as e:
|
|
return []
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_active_costs_for_period(self, period_start: date, period_end: date) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get all active cost items for a specific period.
|
|
|
|
Args:
|
|
period_start: Start date of the period
|
|
period_end: End date of the period
|
|
|
|
Returns:
|
|
List of active cost items for the period
|
|
"""
|
|
conn = self.finance_models.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
cursor.execute('''
|
|
SELECT ci.*, cc.name as category_name
|
|
FROM cost_items ci
|
|
LEFT JOIN cost_categories cc ON ci.category_id = cc.id
|
|
WHERE ci.is_active = TRUE
|
|
AND ci.starting_from_date <= ?
|
|
AND (ci.ending_date IS NULL OR ci.ending_date >= ?)
|
|
ORDER BY ci.category_id, ci.name
|
|
''', (period_end.isoformat(), period_start.isoformat()))
|
|
|
|
rows = cursor.fetchall()
|
|
columns = [desc[0] for desc in cursor.description]
|
|
|
|
return [dict(zip(columns, row)) for row in rows]
|
|
|
|
except sqlite3.Error as e:
|
|
return []
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
def calculate_period_costs(self, period_start: date, period_end: date) -> Dict[str, Any]:
|
|
"""
|
|
Calculate total costs for a specific period.
|
|
|
|
Args:
|
|
period_start: Start date of the period
|
|
period_end: End date of the period
|
|
|
|
Returns:
|
|
Dictionary with cost calculations
|
|
"""
|
|
active_costs = self.get_active_costs_for_period(period_start, period_end)
|
|
|
|
total_monthly = Decimal('0.00')
|
|
total_one_time = Decimal('0.00')
|
|
category_totals = {}
|
|
|
|
for cost_item in active_costs:
|
|
amount = Decimal(str(cost_item['amount_eur']))
|
|
cost_type = cost_item['cost_type']
|
|
category = cost_item['category_name']
|
|
|
|
if cost_type == 'monthly':
|
|
total_monthly += amount
|
|
elif cost_type == 'one_time':
|
|
total_one_time += amount
|
|
|
|
# Track by category
|
|
if category not in category_totals:
|
|
category_totals[category] = {'monthly': Decimal('0.00'), 'one_time': Decimal('0.00')}
|
|
category_totals[category][cost_type] += amount
|
|
|
|
return {
|
|
'period_start': period_start,
|
|
'period_end': period_end,
|
|
'total_monthly': float(total_monthly),
|
|
'total_one_time': float(total_one_time),
|
|
'total_period': float(total_monthly + total_one_time),
|
|
'category_breakdown': {
|
|
cat: {
|
|
'monthly': float(amounts['monthly']),
|
|
'one_time': float(amounts['one_time']),
|
|
'total': float(amounts['monthly'] + amounts['one_time'])
|
|
}
|
|
for cat, amounts in category_totals.items()
|
|
},
|
|
'active_cost_items': len(active_costs)
|
|
}
|
|
|
|
def _validate_cost_item(self, cost_item: CostItem) -> None:
|
|
"""
|
|
Validate cost item data.
|
|
|
|
Args:
|
|
cost_item: CostItem to validate
|
|
|
|
Raises:
|
|
ValueError: If validation fails
|
|
"""
|
|
if not cost_item.name or not cost_item.name.strip():
|
|
raise ValueError("Cost item name is required")
|
|
|
|
if cost_item.cost_type not in ['monthly', 'one_time']:
|
|
raise ValueError("Cost type must be 'monthly' or 'one_time'")
|
|
|
|
if cost_item.amount_eur is None or cost_item.amount_eur < 0:
|
|
raise ValueError("Amount must be non-negative")
|
|
|
|
if not cost_item.starting_from_date:
|
|
raise ValueError("Starting date is required")
|
|
|
|
if cost_item.ending_date and cost_item.ending_date < cost_item.starting_from_date:
|
|
raise ValueError("Ending date must be after starting date")
|
|
|
|
if not cost_item.is_active and cost_item.ending_date is None:
|
|
raise ValueError("Inactive cost items must have an ending date")
|
|
|
|
# Validate category exists
|
|
if cost_item.category_id:
|
|
category = self.get_category(cost_item.category_id)
|
|
if not category:
|
|
raise ValueError(f"Category with ID {cost_item.category_id} does not exist")
|
|
|
|
# Category management methods
|
|
|
|
def create_category(self, name: str, description: Optional[str] = None) -> Optional[int]:
|
|
"""
|
|
Create a new cost category.
|
|
|
|
Args:
|
|
name: Category name
|
|
description: Optional category description
|
|
|
|
Returns:
|
|
ID of created category, or None if creation failed
|
|
"""
|
|
if not name or not name.strip():
|
|
raise ValueError("Category name is required")
|
|
|
|
conn = self.finance_models.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
cursor.execute('''
|
|
INSERT INTO cost_categories (name, description, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (name.strip(), description, datetime.now().isoformat(), datetime.now().isoformat()))
|
|
|
|
category_id = cursor.lastrowid
|
|
conn.commit()
|
|
|
|
return category_id
|
|
|
|
except sqlite3.IntegrityError:
|
|
conn.rollback()
|
|
raise ValueError(f"Category '{name}' already exists")
|
|
|
|
except sqlite3.Error as e:
|
|
conn.rollback()
|
|
raise
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_category(self, category_id: int) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Retrieve a category by ID.
|
|
|
|
Args:
|
|
category_id: ID of category to retrieve
|
|
|
|
Returns:
|
|
Dictionary containing category data, or None if not found
|
|
"""
|
|
conn = self.finance_models.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
cursor.execute('SELECT * FROM cost_categories WHERE id = ?', (category_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
return None
|
|
|
|
columns = [desc[0] for desc in cursor.description]
|
|
return dict(zip(columns, row))
|
|
|
|
except sqlite3.Error as e:
|
|
return None
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
def list_categories(self) -> List[Dict[str, Any]]:
|
|
"""
|
|
List all cost categories.
|
|
|
|
Returns:
|
|
List of category dictionaries
|
|
"""
|
|
conn = self.finance_models.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
cursor.execute('SELECT * FROM cost_categories ORDER BY name')
|
|
rows = cursor.fetchall()
|
|
columns = [desc[0] for desc in cursor.description]
|
|
|
|
return [dict(zip(columns, row)) for row in rows]
|
|
|
|
except sqlite3.Error as e:
|
|
return []
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_category_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Retrieve a category by name.
|
|
|
|
Args:
|
|
name: Name of category to retrieve
|
|
|
|
Returns:
|
|
Dictionary containing category data, or None if not found
|
|
"""
|
|
conn = self.finance_models.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
cursor.execute('SELECT * FROM cost_categories WHERE name = ?', (name,))
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
return None
|
|
|
|
columns = [desc[0] for desc in cursor.description]
|
|
return dict(zip(columns, row))
|
|
|
|
except sqlite3.Error as e:
|
|
return None
|
|
|
|
finally:
|
|
conn.close() |