bmc_hub/app/economy/backend/router.py

809 lines
29 KiB
Python
Raw Normal View History

import logging
from collections import defaultdict
from datetime import date
from decimal import Decimal
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.core.config import settings
from app.core.database import execute_insert, execute_query, execute_query_single, execute_update
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/economy", tags=["Economy"])
class BulkIdsRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
class BulkUpdateRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
description: Optional[str] = None
original_hours: Optional[float] = Field(None, gt=0)
billable: Optional[bool] = None
billing_method: Optional[str] = None
class BulkSoftDeleteRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
reason: Optional[str] = "Soft deleted from economy queue"
class BulkApproveRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
billable: Optional[bool] = None
billing_method: Optional[str] = None
class BulkPrepaidRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
prepaid_card_id: int = Field(..., gt=0)
class BulkSendRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
def _ensure_ids(ids: List[int]) -> List[int]:
clean = sorted(set(int(i) for i in ids if int(i) > 0))
if not clean:
raise HTTPException(status_code=400, detail="No valid ids provided")
return clean
@router.get("/time-queue")
async def list_hub_time_queue(
customer_id: Optional[int] = Query(None, gt=0),
status: Optional[str] = Query(None),
billable: Optional[bool] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(500, ge=1, le=2000),
):
"""List non-billed Hub-created time entries for the economy queue."""
try:
conditions = [
"t.vtiger_id IS NULL",
"t.billed_via_thehub_id IS NULL",
"t.status <> 'billed'",
]
params: List[Any] = []
if customer_id is not None:
conditions.append("t.customer_id = %s")
params.append(customer_id)
if status:
conditions.append("t.status = %s")
params.append(status)
if billable is not None:
conditions.append("COALESCE(t.billable, true) = %s")
params.append(billable)
if q:
conditions.append(
"("
"COALESCE(t.description, '') ILIKE %s OR "
"COALESCE(cust.name, '') ILIKE %s OR "
"COALESCE(c.title, s.titel, '') ILIKE %s"
")"
)
like = f"%{q}%"
params.extend([like, like, like])
where_sql = " AND ".join(conditions)
query = f"""
SELECT
t.id,
t.customer_id,
cust.name AS customer_name,
t.status,
t.entry_status,
t.billable,
t.billing_method,
t.prepaid_card_id,
t.fixed_price_agreement_id,
t.original_hours,
t.approved_hours,
t.rounded_to,
t.worked_date,
t.description,
t.entry_type,
t.kilde,
t.case_id,
t.sag_id,
COALESCE(c.title, s.titel, 'No title') AS case_title,
t.created_at,
t.updated_at
FROM tmodule_times t
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE {where_sql}
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC
LIMIT %s
"""
params.append(limit)
rows = execute_query(query, tuple(params))
return {"items": rows, "count": len(rows)}
except HTTPException:
raise
except Exception as e:
logger.error("Failed listing economy time queue: %s", e)
raise HTTPException(status_code=500, detail="Failed to list time queue")
@router.get("/time-queue/customers")
async def list_time_queue_customers():
"""List customers that currently have queue-relevant (not billed) Hub entries."""
try:
rows = execute_query(
"""
SELECT
t.customer_id,
COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) AS customer_name,
COUNT(*)::int AS open_count
FROM tmodule_times t
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
WHERE t.customer_id IS NOT NULL
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.status = 'pending'
GROUP BY t.customer_id, cust.name
ORDER BY COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) ASC
"""
)
return {"items": rows, "count": len(rows)}
except Exception as e:
logger.error("Failed listing time queue customers: %s", e)
raise HTTPException(status_code=500, detail="Failed listing customer filter options")
@router.get("/time-queue/prepaid-cards")
async def list_prepaid_cards():
try:
cards = execute_query(
"""
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours, remaining_hours, status, expires_at
FROM tticket_prepaid_cards
WHERE status IN ('active', 'depleted')
ORDER BY remaining_hours DESC, id DESC
"""
)
return {"items": cards, "count": len(cards)}
except Exception as e:
logger.error("Failed listing prepaid cards: %s", e)
raise HTTPException(status_code=500, detail="Failed to list prepaid cards")
@router.patch("/time-queue/bulk-update")
async def bulk_update_time_queue(payload: BulkUpdateRequest):
ids = _ensure_ids(payload.ids)
updates: List[str] = []
values: List[Any] = []
if payload.description is not None:
updates.append("description = %s")
values.append(payload.description)
if payload.original_hours is not None:
updates.append("original_hours = %s")
values.append(payload.original_hours)
if payload.billable is not None:
updates.append("billable = %s")
values.append(payload.billable)
if payload.billable is False and payload.billing_method is None:
updates.append("billing_method = 'internal'")
if payload.billing_method is not None:
updates.append("billing_method = %s")
values.append(payload.billing_method)
if not updates:
raise HTTPException(status_code=400, detail="No update fields provided")
try:
placeholders = ",".join(["%s"] * len(ids))
query = f"""
UPDATE tmodule_times
SET {", ".join(updates)}
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(values + ids))
return {"success": True, "updated": len(ids)}
except Exception as e:
logger.error("Failed bulk update: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk update")
@router.post("/time-queue/bulk-soft-delete")
async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
ids = _ensure_ids(payload.ids)
reason = (payload.reason or "Soft deleted from economy queue").strip()
try:
placeholders = ",".join(["%s"] * len(ids))
execute_update(
f"""
UPDATE tmodule_times
SET status = 'rejected',
entry_status = 'kladde',
approval_note = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
""",
tuple([reason] + ids),
)
return {"success": True, "soft_deleted": len(ids)}
except Exception as e:
logger.error("Failed bulk soft delete: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk soft delete")
@router.post("/time-queue/bulk-approve")
async def bulk_approve_time_queue(payload: BulkApproveRequest):
ids = _ensure_ids(payload.ids)
try:
set_parts = [
"status = 'approved'",
"entry_status = 'godkendt'",
"approved_hours = COALESCE(approved_hours, original_hours)",
"approved_at = CURRENT_TIMESTAMP",
"updated_at = CURRENT_TIMESTAMP",
]
params: List[Any] = []
if payload.billable is not None:
set_parts.append("billable = %s")
params.append(payload.billable)
if payload.billing_method is not None:
set_parts.append("billing_method = %s")
params.append(payload.billing_method)
placeholders = ",".join(["%s"] * len(ids))
query = f"""
UPDATE tmodule_times
SET {", ".join(set_parts)}
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(params + ids))
return {"success": True, "approved": len(ids)}
except Exception as e:
logger.error("Failed bulk approve: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk approve")
@router.post("/time-queue/bulk-apply-prepaid")
async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
ids = _ensure_ids(payload.ids)
card = execute_query_single(
"SELECT id FROM tticket_prepaid_cards WHERE id = %s",
(payload.prepaid_card_id,),
)
if not card:
raise HTTPException(status_code=404, detail="Prepaid card not found")
try:
placeholders = ",".join(["%s"] * len(ids))
execute_update(
f"""
UPDATE tmodule_times
SET prepaid_card_id = %s,
billing_method = 'prepaid',
billable = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
""",
tuple([payload.prepaid_card_id] + ids),
)
return {"success": True, "updated": len(ids), "prepaid_card_id": payload.prepaid_card_id}
except Exception as e:
logger.error("Failed applying prepaid card: %s", e)
raise HTTPException(status_code=500, detail="Failed applying prepaid card")
def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
customer = execute_query_single(
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"rows": [],
"case_title": "Time entries",
"case_id": None,
"sag_id": None,
})
for row in rows:
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
grouped[group_key]["rows"].append(row)
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
grouped[group_key]["case_id"] = row.get("case_id")
grouped[group_key]["sag_id"] = row.get("sag_id")
line_payloads: List[Dict[str, Any]] = []
total_hours = Decimal("0")
for _, group in grouped.items():
qty = Decimal("0")
ids: List[int] = []
latest_date = None
for row in group["rows"]:
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
ids.append(int(row["id"]))
wd = row.get("worked_date")
if wd and (latest_date is None or wd > latest_date):
latest_date = wd
line_total = (qty * hourly_rate).quantize(Decimal("0.01"))
line_payloads.append(
{
"description": group["case_title"],
"quantity": qty,
"line_total": line_total,
"time_entry_ids": ids,
"case_id": group["case_id"],
"sag_id": group["sag_id"],
"time_date": latest_date,
}
)
total_hours += qty
subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01"))
vat_rate = Decimal("25.00")
vat_amount = (subtotal * vat_rate / Decimal("100")).quantize(Decimal("0.01"))
total_amount = subtotal + vat_amount
order_id = execute_insert(
"""
INSERT INTO tmodule_orders
(customer_id, hub_customer_id, order_date, total_hours, hourly_rate,
subtotal, vat_rate, vat_amount, total_amount, status, created_by)
VALUES
(%s, %s, CURRENT_DATE, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING id
""",
(
customer_id,
customer.get("hub_customer_id"),
total_hours,
hourly_rate,
subtotal,
vat_rate,
vat_amount,
total_amount,
user_id,
),
)
for idx, line in enumerate(line_payloads, start=1):
execute_insert(
"""
INSERT INTO tmodule_order_lines
(order_id, case_id, sag_id, line_number, description, quantity, unit_price,
line_total, time_entry_ids, case_contact, time_date, is_travel)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, FALSE)
RETURNING id
""",
(
order_id,
line["case_id"],
line["sag_id"],
idx,
line["description"],
line["quantity"],
hourly_rate,
line["line_total"],
line["time_entry_ids"],
line["time_date"],
),
)
return int(order_id)
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
customer = execute_query_single(
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
hub_customer_id = customer.get("hub_customer_id")
hub_customer = None
if hub_customer_id:
hub_customer = execute_query_single(
"""
SELECT
standard_hourly_rate,
standard_margin_percent,
special_freight_price,
supplier_service_enrolled,
invoice_fee_amount
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
)
invoice_fee_amount = Decimal(
str(
(hub_customer or {}).get("invoice_fee_amount")
if (hub_customer or {}).get("invoice_fee_amount") is not None
else settings.CUSTOMER_DEFAULT_INVOICE_FEE
)
)
special_freight_price = (hub_customer or {}).get("special_freight_price")
special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0")
supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled"))
standard_margin_percent = Decimal(
str(
(hub_customer or {}).get("standard_margin_percent")
if (hub_customer or {}).get("standard_margin_percent") is not None
else settings.CUSTOMER_DEFAULT_MARGIN_PERCENT
)
)
base_hourly_rate = Decimal(
str(
(hub_customer or {}).get("standard_hourly_rate")
if (hub_customer or {}).get("standard_hourly_rate") is not None
else hourly_rate
)
)
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"rows": [],
"case_title": "Time entries",
"case_id": None,
"sag_id": None,
})
for row in rows:
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
grouped[group_key]["rows"].append(row)
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
grouped[group_key]["case_id"] = row.get("case_id")
grouped[group_key]["sag_id"] = row.get("sag_id")
line_payloads: List[Dict[str, Any]] = []
for _, group in grouped.items():
qty = Decimal("0")
ids: List[int] = []
latest_date = None
for row in group["rows"]:
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
ids.append(int(row["id"]))
wd = row.get("worked_date")
if wd and (latest_date is None or wd > latest_date):
latest_date = wd
effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0")
unit_price = base_hourly_rate.quantize(Decimal("0.01"))
amount = (qty * unit_price).quantize(Decimal("0.01"))
line_payloads.append(
{
"line_key": f"timequeue:{ids[0] if ids else 0}:{group.get('case_id') or 0}:{group.get('sag_id') or 0}",
"source_type": "timequeue",
"source_id": ids[0] if ids else None,
"description": group["case_title"],
"quantity": float(qty),
"unit_price": float(unit_price),
"discount_percentage": 0,
"unit": "timer",
"product_id": None,
"selected": True,
"amount": float(amount),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": group["sag_id"],
"time_entry_ids": ids,
"time_date": str(latest_date) if latest_date else None,
"meta": {
"base_hourly_rate": float(base_hourly_rate.quantize(Decimal("0.01"))),
"standard_margin_percent": float(effective_margin_percent),
},
}
)
if special_freight_amount > 0:
line_payloads.append(
{
"line_key": f"freight:{hub_customer_id or customer_id}",
"source_type": "freight",
"source_id": None,
"description": "Særlig fragtpris",
"quantity": 1.0,
"unit_price": float(special_freight_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
}
)
# Fee line is included by default unless customer-specific value is 0.
if invoice_fee_amount > 0 and not supplier_service_enrolled:
line_payloads.append(
{
"line_key": f"invoice_fee:{hub_customer_id or customer_id}",
"source_type": "invoice_fee",
"source_id": None,
"description": "Faktureringsgebyr",
"quantity": 1.0,
"unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
"meta": {
"standard_margin_percent": float(standard_margin_percent),
"supplier_service_enrolled": supplier_service_enrolled,
},
}
)
if not line_payloads:
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
draft_title = f"Timefaktura {customer.get('name') or f'Kunde {customer_id}'} - {date.today().isoformat()}"
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}"
draft = execute_query_single(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
invoice_aggregate_key,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
RETURNING id
""",
(
draft_title,
int(hub_customer_id) if hub_customer_id else None,
json.dumps(line_payloads, ensure_ascii=False),
"Genereret fra Economy Time Queue",
1,
user_id,
json.dumps({}, ensure_ascii=False),
invoice_aggregate_key,
),
)
if not draft:
raise HTTPException(status_code=500, detail="Failed creating ordre draft")
return int(draft["id"])
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
"""Resolve any incoming customer reference to a valid tmodule_customers.id.
Accepts:
- direct tmodule customer id
- hub customer id (customers.id) via tmodule_customers.hub_customer_id
- fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id
"""
def _find_by_tmodule_id(candidate_id: int) -> Optional[int]:
row = execute_query_single("SELECT id FROM tmodule_customers WHERE id = %s", (candidate_id,))
return int(row["id"]) if row else None
def _find_by_hub_customer_id(hub_customer_id: int) -> Optional[int]:
row = execute_query_single(
"""
SELECT id
FROM tmodule_customers
WHERE hub_customer_id = %s
ORDER BY id ASC
LIMIT 1
""",
(hub_customer_id,),
)
return int(row["id"]) if row else None
if raw_customer_id is not None:
try:
cid = int(raw_customer_id)
except (TypeError, ValueError):
cid = None
if cid and cid > 0:
direct = _find_by_tmodule_id(cid)
if direct:
return direct
mapped = _find_by_hub_customer_id(cid)
if mapped:
return mapped
if sag_id is not None:
try:
sid = int(sag_id)
except (TypeError, ValueError):
sid = None
if sid and sid > 0:
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sid,))
hub_customer_id = (sag or {}).get("customer_id") if sag else None
if hub_customer_id:
mapped = _find_by_hub_customer_id(int(hub_customer_id))
if mapped:
return mapped
return None
@router.post("/time-queue/send-to-invoices")
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
ids = _ensure_ids(payload.ids)
user_id = getattr(request.state, "user_id", None)
try:
placeholders = ",".join(["%s"] * len(ids))
rows = execute_query(
f"""
SELECT
t.id,
t.customer_id,
t.case_id,
t.sag_id,
t.status,
t.billable,
t.billing_method,
t.original_hours,
t.approved_hours,
t.worked_date,
COALESCE(c.title, s.titel, 'Time entries') AS case_title
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.id IN ({placeholders})
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.status <> 'billed'
""",
tuple(ids),
)
if not rows:
raise HTTPException(status_code=400, detail="No eligible entries found")
# Local order creation must not depend on e-conomic data/mapping.
# Selected entries are converted to local orders regardless of billing method.
selected_order_ids = [int(r["id"]) for r in rows]
if not selected_order_ids:
raise HTTPException(status_code=400, detail="No selected entries found")
placeholders_invoice = ",".join(["%s"] * len(selected_order_ids))
execute_update(
f"""
UPDATE tmodule_times
SET status = 'approved',
entry_status = 'godkendt',
approved_hours = COALESCE(approved_hours, original_hours),
approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders_invoice})
AND status <> 'billed'
""",
tuple(selected_order_ids),
)
rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
skipped_missing_customer: List[int] = []
for row in rows:
if int(row["id"]) not in selected_order_ids:
continue
resolved_customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
if not resolved_customer_id:
skipped_missing_customer.append(int(row["id"]))
continue
rows_by_customer[int(resolved_customer_id)].append(row)
created_drafts = []
failed_customers: List[Dict[str, Any]] = []
for cust_id, cust_rows in rows_by_customer.items():
try:
draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id)
created_drafts.append({"customer_id": cust_id, "draft_id": draft_id})
except HTTPException as ex:
failed_customers.append(
{
"customer_id": cust_id,
"entry_ids": [int(r.get("id")) for r in cust_rows if r.get("id") is not None],
"error": str(ex.detail),
}
)
if not created_drafts:
if skipped_missing_customer:
raise HTTPException(
status_code=400,
detail="No local orders created: selected entries are missing customer linkage",
)
if failed_customers:
raise HTTPException(
status_code=400,
detail="No local orders created: customer data is invalid for selected entries",
)
raise HTTPException(status_code=400, detail="No local orders created")
# Time queue must never push directly to e-conomic.
# Orders are created locally and can be transferred manually from Orders page.
draft_ids = [o["draft_id"] for o in created_drafts]
orders_url = "/ordre"
if len(draft_ids) == 1:
orders_url = f"/ordre/{draft_ids[0]}"
return {
"success": True,
"selected": len(ids),
"order_candidates": len(selected_order_ids),
"created_drafts": created_drafts,
"created_orders": [{"customer_id": d["customer_id"], "order_id": d["draft_id"]} for d in created_drafts],
"skipped_missing_customer": skipped_missing_customer,
"failed_customers": failed_customers,
"orders_url": orders_url,
"message": "Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel.",
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed send-to-invoices flow: %s", e)
raise HTTPException(status_code=500, detail="Failed sending selected entries to invoices")