b981c06d59
- Fix unit test imports: add env setup in conftest.py before module imports - Add 24 new auth router tests (RBAC, preferences, password validation) - Add 16 new tests for litellm and chat_summary routers - Apply black formatting and ruff linting across codebase - Add pre-commit hooks configuration (black, ruff, file checks) - Increase CI coverage threshold from 40% to 50% Test Results: - 206 tests passing (91 unit + 115 integration) - Coverage: 58.79% on core modules - auth.py: 57% → 85%, litellm.py: 23% → 87%, chat_summary.py: 41% → 100% - auth_service: 96.51%, config: 100%, rbac: 93.48%
195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
"""
|
|
PDF Invoice Generation Service
|
|
"""
|
|
|
|
import io
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import letter
|
|
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
|
from reportlab.lib.units import inch
|
|
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
|
|
|
|
|
def generate_invoice_pdf(
|
|
invoice_number: str,
|
|
client: dict[str, Any],
|
|
time_entries: list[dict[str, Any]],
|
|
hourly_rate: float = 100.0,
|
|
company_name: str = "Your Company",
|
|
company_address: str = "",
|
|
company_email: str = "",
|
|
company_phone: str = "",
|
|
) -> bytes:
|
|
"""
|
|
Generate PDF invoice from time entries
|
|
|
|
Returns: PDF bytes
|
|
"""
|
|
buffer = io.BytesIO()
|
|
doc = SimpleDocTemplate(buffer, pagesize=letter)
|
|
elements = []
|
|
styles = getSampleStyleSheet()
|
|
|
|
# Custom styles
|
|
title_style = ParagraphStyle(
|
|
"CustomTitle",
|
|
parent=styles["Heading1"],
|
|
fontSize=24,
|
|
textColor=colors.HexColor("#1F2937"),
|
|
spaceAfter=30,
|
|
)
|
|
|
|
header_style = ParagraphStyle(
|
|
"Header",
|
|
parent=styles["Normal"],
|
|
fontSize=10,
|
|
textColor=colors.HexColor("#6B7280"),
|
|
)
|
|
|
|
# Company Header
|
|
elements.append(Paragraph(company_name, title_style))
|
|
if company_address:
|
|
elements.append(Paragraph(company_address, header_style))
|
|
if company_email:
|
|
elements.append(Paragraph(f"Email: {company_email}", header_style))
|
|
if company_phone:
|
|
elements.append(Paragraph(f"Phone: {company_phone}", header_style))
|
|
|
|
elements.append(Spacer(1, 0.3 * inch))
|
|
|
|
# Invoice Title
|
|
invoice_title = ParagraphStyle(
|
|
"InvoiceTitle",
|
|
parent=styles["Heading2"],
|
|
fontSize=18,
|
|
textColor=colors.HexColor("#4F46E5"),
|
|
)
|
|
elements.append(Paragraph("INVOICE", invoice_title))
|
|
elements.append(Spacer(1, 0.2 * inch))
|
|
|
|
# Invoice Details
|
|
invoice_date = datetime.now().strftime("%B %d, %Y")
|
|
invoice_data = [
|
|
["Invoice Number:", invoice_number],
|
|
["Invoice Date:", invoice_date],
|
|
["Client:", client.get("name", "N/A")],
|
|
]
|
|
|
|
if client.get("company"):
|
|
invoice_data.append(["Company:", client["company"]])
|
|
if client.get("email"):
|
|
invoice_data.append(["Email:", client["email"]])
|
|
|
|
invoice_table = Table(invoice_data, colWidths=[2 * inch, 4 * inch])
|
|
invoice_table.setStyle(
|
|
TableStyle(
|
|
[
|
|
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
|
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
|
|
("FONTSIZE", (0, 0), (-1, -1), 10),
|
|
("TEXTCOLOR", (0, 0), (0, -1), colors.HexColor("#374151")),
|
|
("TEXTCOLOR", (1, 0), (1, -1), colors.HexColor("#1F2937")),
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
|
]
|
|
)
|
|
)
|
|
elements.append(invoice_table)
|
|
elements.append(Spacer(1, 0.4 * inch))
|
|
|
|
# Time Entries Table
|
|
table_data = [["Date", "Description", "Hours", "Rate", "Amount"]]
|
|
|
|
total_hours = 0
|
|
total_amount = 0
|
|
|
|
for entry in time_entries:
|
|
hours = entry["duration_minutes"] / 60
|
|
rate = entry.get("hourly_rate", hourly_rate)
|
|
amount = hours * rate
|
|
|
|
total_hours += hours
|
|
total_amount += amount
|
|
|
|
date_str = entry["started_at"].strftime("%Y-%m-%d") if entry.get("started_at") else "N/A"
|
|
desc = entry.get("description", "Work performed")
|
|
|
|
table_data.append(
|
|
[date_str, desc[:50] + "..." if len(desc) > 50 else desc, f"{hours:.2f}", f"${rate:.2f}", f"${amount:.2f}"]
|
|
)
|
|
|
|
# Add totals row
|
|
table_data.append(["", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}"])
|
|
|
|
# Create table
|
|
time_table = Table(table_data, colWidths=[1.2 * inch, 2.8 * inch, 0.8 * inch, 0.8 * inch, 1 * inch])
|
|
time_table.setStyle(
|
|
TableStyle(
|
|
[
|
|
# Header row
|
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4F46E5")),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0, 0), (-1, 0), 11),
|
|
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
|
|
# Data rows
|
|
("FONTNAME", (0, 1), (-1, -2), "Helvetica"),
|
|
("FONTSIZE", (0, 1), (-1, -2), 9),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, colors.HexColor("#F9FAFB")]),
|
|
("GRID", (0, 0), (-1, -2), 0.5, colors.HexColor("#E5E7EB")),
|
|
# Total row
|
|
("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#F3F4F6")),
|
|
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
|
|
("FONTSIZE", (0, -1), (-1, -1), 11),
|
|
("TOPPADDING", (0, -1), (-1, -1), 12),
|
|
("BOTTOMPADDING", (0, -1), (-1, -1), 12),
|
|
# Alignment
|
|
("ALIGN", (2, 0), (-1, -1), "RIGHT"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
]
|
|
)
|
|
)
|
|
|
|
elements.append(time_table)
|
|
elements.append(Spacer(1, 0.5 * inch))
|
|
|
|
# Payment Terms
|
|
terms_style = ParagraphStyle(
|
|
"Terms",
|
|
parent=styles["Normal"],
|
|
fontSize=9,
|
|
textColor=colors.HexColor("#6B7280"),
|
|
)
|
|
elements.append(Paragraph("<b>Payment Terms:</b> Due within 30 days", terms_style))
|
|
elements.append(Spacer(1, 0.1 * inch))
|
|
elements.append(Paragraph("Thank you for your business!", terms_style))
|
|
|
|
# Build PDF
|
|
doc.build(elements)
|
|
|
|
pdf_bytes = buffer.getvalue()
|
|
buffer.close()
|
|
|
|
return pdf_bytes
|
|
|
|
|
|
def generate_invoice_from_project(
|
|
project_id: int, client: dict[str, Any], time_entries: list[dict[str, Any]], invoice_number: str = None
|
|
) -> bytes:
|
|
"""Generate invoice for a project"""
|
|
if not invoice_number:
|
|
invoice_number = f"INV-{datetime.now().strftime('%Y%m%d')}-{project_id}"
|
|
|
|
return generate_invoice_pdf(
|
|
invoice_number=invoice_number,
|
|
client=client,
|
|
time_entries=time_entries,
|
|
company_name="Your OPC",
|
|
company_address="123 Business St, City, State 12345",
|
|
company_email="billing@youropc.com",
|
|
company_phone="+1 (555) 123-4567",
|
|
)
|