mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-24 07:10:21 -05:00
b4486a627f
- 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
147 lines
5.3 KiB
Python
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)
|