Files
TimeTracker/app/services/expense_service.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

228 lines
8.1 KiB
Python

"""
Service for expense business logic.
"""
from datetime import date
from decimal import Decimal
from typing import Any, Dict, List, Optional
from app import db
from app.models import Expense
from app.repositories import ExpenseRepository, ProjectRepository
from app.utils.db import safe_commit
class ExpenseService:
"""Service for expense operations"""
def __init__(self):
self.expense_repo = ExpenseRepository()
self.project_repo = ProjectRepository()
def create_expense(
self,
amount: Decimal,
expense_date: date,
created_by: int,
title: Optional[str] = None,
description: Optional[str] = None,
project_id: Optional[int] = None,
client_id: Optional[int] = None,
category: Optional[str] = None,
category_id: Optional[int] = None,
billable: bool = False,
reimbursable: bool = True,
currency_code: Optional[str] = None,
tax_amount: Optional[Decimal] = None,
tax_rate: Optional[Decimal] = None,
payment_method: Optional[str] = None,
payment_date: Optional[date] = None,
tags: Optional[str] = None,
receipt_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a new expense.
Returns:
dict with 'success', 'message', and 'expense' keys
"""
# Validate project if provided
if project_id:
project = self.project_repo.get_by_id(project_id)
if not project:
return {"success": False, "message": "Invalid project", "error": "invalid_project"}
# Validate amount
if amount <= 0:
return {"success": False, "message": "Amount must be greater than zero", "error": "invalid_amount"}
# Use model directly for full field support
from app.models import Expense
expense = Expense(
user_id=created_by,
title=title or description or "Expense",
category=category,
amount=amount,
expense_date=expense_date,
description=description,
project_id=project_id,
client_id=client_id,
currency_code=currency_code or "EUR",
tax_amount=tax_amount or Decimal("0.00"),
tax_rate=tax_rate or Decimal("0.00"),
payment_method=payment_method,
payment_date=payment_date,
billable=billable,
reimbursable=reimbursable,
tags=tags,
receipt_path=receipt_path,
)
db.session.add(expense)
if not safe_commit("create_expense", {"project_id": project_id, "created_by": created_by}):
return {
"success": False,
"message": "Could not create expense due to a database error",
"error": "database_error",
}
return {"success": True, "message": "Expense created successfully", "expense": expense}
def get_project_expenses(
self, project_id: int, start_date: Optional[date] = None, end_date: Optional[date] = None
) -> List[Expense]:
"""Get expenses for a project"""
return self.expense_repo.get_by_project(
project_id=project_id, start_date=start_date, end_date=end_date, include_relations=True
)
def get_total_expenses(
self,
project_id: Optional[int] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
billable_only: bool = False,
) -> float:
"""Get total expense amount"""
return self.expense_repo.get_total_amount(
project_id=project_id, start_date=start_date, end_date=end_date, billable_only=billable_only
)
def list_expenses(
self,
user_id: Optional[int] = None,
project_id: Optional[int] = None,
client_id: Optional[int] = None,
status: Optional[str] = None,
category: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
is_admin: bool = False,
page: int = 1,
per_page: int = 50,
) -> Dict[str, Any]:
"""
List expenses with filtering and pagination.
Uses eager loading to prevent N+1 queries.
Returns:
dict with 'expenses' and 'pagination' keys
"""
from sqlalchemy.orm import joinedload
query = self.expense_repo.query()
# Eagerly load relations to prevent N+1
query = query.options(joinedload(Expense.project), joinedload(Expense.user), joinedload(Expense.client))
# Permission filter - non-admins only see their expenses
if not is_admin and user_id:
query = query.filter(Expense.user_id == user_id)
# Apply filters
if project_id:
query = query.filter(Expense.project_id == project_id)
if client_id:
query = query.filter(Expense.client_id == client_id)
if status:
query = query.filter(Expense.status == status)
if category:
query = query.filter(Expense.category == category)
if start_date:
query = query.filter(Expense.expense_date >= start_date)
if end_date:
query = query.filter(Expense.expense_date <= end_date)
# Order and paginate
query = query.order_by(Expense.expense_date.desc(), Expense.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {"expenses": pagination.items, "pagination": pagination}
def update_expense(self, expense_id: int, user_id: int, is_admin: bool = False, **kwargs) -> Dict[str, Any]:
"""
Update an expense.
Returns:
dict with 'success', 'message', and 'expense' keys
"""
expense = self.expense_repo.get_by_id(expense_id)
if not expense:
return {"success": False, "message": "Expense not found", "error": "not_found"}
# Check permissions
if not is_admin and expense.user_id != user_id:
return {"success": False, "message": "Access denied", "error": "access_denied"}
# Update fields
for field in ("title", "description", "category", "currency_code", "payment_method", "status", "tags"):
if field in kwargs:
setattr(expense, field, kwargs[field])
if "amount" in kwargs:
expense.amount = kwargs["amount"]
if "expense_date" in kwargs:
expense.expense_date = kwargs["expense_date"]
if "payment_date" in kwargs:
expense.payment_date = kwargs["payment_date"]
for bfield in ("billable", "reimbursable", "reimbursed", "invoiced"):
if bfield in kwargs:
setattr(expense, bfield, bool(kwargs[bfield]))
if not safe_commit("update_expense", {"expense_id": expense_id, "user_id": user_id}):
return {
"success": False,
"message": "Could not update expense due to a database error",
"error": "database_error",
}
return {"success": True, "message": "Expense updated successfully", "expense": expense}
def delete_expense(self, expense_id: int, user_id: int, is_admin: bool = False) -> Dict[str, Any]:
"""
Delete (reject) an expense.
Returns:
dict with 'success' and 'message' keys
"""
expense = self.expense_repo.get_by_id(expense_id)
if not expense:
return {"success": False, "message": "Expense not found", "error": "not_found"}
# Check permissions
if not is_admin and expense.user_id != user_id:
return {"success": False, "message": "Access denied", "error": "access_denied"}
# Soft delete by setting status to rejected
expense.status = "rejected"
if not safe_commit("delete_expense", {"expense_id": expense_id, "user_id": user_id}):
return {
"success": False,
"message": "Could not delete expense due to a database error",
"error": "database_error",
}
return {"success": True, "message": "Expense rejected successfully"}