Files
TimeTracker/app/services/recurring_invoice_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

147 lines
5.3 KiB
Python

"""
Service for recurring invoice business logic (generation from template, list, get).
"""
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any, Dict, List, Optional
from app import db
from app.models import Invoice, InvoiceItem, RecurringInvoice, Settings, TimeEntry
from app.repositories.recurring_invoice_repository import RecurringInvoiceRepository
class RecurringInvoiceService:
"""Service for recurring invoice operations."""
def __init__(self):
self.repo = RecurringInvoiceRepository()
def list_recurring_invoices(
self,
user_id: int,
is_admin: bool,
is_active: Optional[bool] = None,
) -> List[RecurringInvoice]:
"""List recurring invoices for the user (or all if admin)."""
return self.repo.list_for_user(
created_by=user_id,
is_admin=is_admin,
is_active=is_active,
)
def get_by_id(self, recurring_invoice_id: int) -> Optional[RecurringInvoice]:
"""Get a recurring invoice by id."""
return self.repo.get_by_id(recurring_invoice_id)
def generate_invoice(self, recurring_invoice):
"""
Generate an invoice from a recurring invoice template.
Args:
recurring_invoice: RecurringInvoice model instance.
Returns:
Invoice instance if generated, None if should_generate_today() is False.
"""
if not recurring_invoice.should_generate_today():
return None
settings = Settings.get_settings()
currency_code = recurring_invoice.currency_code or (settings.currency if settings else "EUR")
issue_date = datetime.utcnow().date()
due_date = issue_date + timedelta(days=recurring_invoice.due_date_days)
invoice_number = Invoice.generate_invoice_number()
invoice = Invoice(
invoice_number=invoice_number,
project_id=recurring_invoice.project_id,
client_name=recurring_invoice.client_name,
due_date=due_date,
created_by=recurring_invoice.created_by,
client_id=recurring_invoice.client_id,
client_email=recurring_invoice.client_email,
client_address=recurring_invoice.client_address,
tax_rate=recurring_invoice.tax_rate,
notes=recurring_invoice.notes,
terms=recurring_invoice.terms,
currency_code=currency_code,
template_id=recurring_invoice.template_id,
issue_date=issue_date,
)
invoice.recurring_invoice_id = recurring_invoice.id
db.session.add(invoice)
if recurring_invoice.auto_include_time_entries:
self._add_time_entry_items(recurring_invoice, invoice)
invoice.calculate_totals()
recurring_invoice.last_generated_at = datetime.utcnow()
recurring_invoice.next_run_date = recurring_invoice.calculate_next_run_date(issue_date)
return invoice
def _add_time_entry_items(self, recurring_invoice, invoice):
"""Add invoice items from unbilled time entries for the recurring invoice's project."""
time_entries = (
TimeEntry.query.filter(
TimeEntry.project_id == recurring_invoice.project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True,
)
.order_by(TimeEntry.start_time.desc())
.all()
)
unbilled_entries = []
for entry in time_entries:
already_billed = False
for other_invoice in recurring_invoice.project.invoices:
if other_invoice.id != invoice.id:
for item in other_invoice.items:
if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(","):
already_billed = True
break
if already_billed:
break
if not already_billed:
unbilled_entries.append(entry)
if not unbilled_entries:
return
from app.models.rate_override import RateOverride
grouped_entries = {}
for entry in unbilled_entries:
if entry.task_id:
key = f"task_{entry.task_id}"
description = f"Task: {entry.task.name if entry.task else 'Unknown Task'}"
else:
key = f"project_{entry.project_id}"
description = f"Project: {entry.project.name}"
if key not in grouped_entries:
grouped_entries[key] = {
"description": description,
"entries": [],
"total_hours": Decimal("0"),
}
grouped_entries[key]["entries"].append(entry)
grouped_entries[key]["total_hours"] += entry.duration_hours
hourly_rate = RateOverride.resolve_rate(recurring_invoice.project)
for group in grouped_entries.values():
if group["total_hours"] > 0:
item = InvoiceItem(
invoice_id=invoice.id,
description=group["description"],
quantity=group["total_hours"],
unit_price=hourly_rate,
time_entry_ids=",".join(str(e.id) for e in group["entries"]),
)
db.session.add(item)