
- Set up project structure with modular organization - Implement database models for users, organizations, clients, invoices - Create Alembic migration scripts for database setup - Implement JWT-based authentication and authorization - Create API endpoints for users, organizations, clients, invoices - Add PDF generation for invoices using ReportLab - Add comprehensive documentation in README
273 lines
8.6 KiB
Python
273 lines
8.6 KiB
Python
import io
|
|
from reportlab.lib.pagesizes import letter
|
|
from reportlab.lib import colors
|
|
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.units import inch
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.invoice import Invoice
|
|
|
|
|
|
def generate_invoice_pdf(db: Session, invoice: Invoice) -> bytes:
|
|
"""
|
|
Generate a PDF document for an invoice.
|
|
|
|
Args:
|
|
db: Database session
|
|
invoice: Invoice model instance
|
|
|
|
Returns:
|
|
Bytes containing the PDF content
|
|
"""
|
|
# Create a BytesIO buffer to receive the PDF data
|
|
buffer = io.BytesIO()
|
|
|
|
# Create the PDF document using ReportLab
|
|
doc = SimpleDocTemplate(
|
|
buffer,
|
|
pagesize=letter,
|
|
rightMargin=72,
|
|
leftMargin=72,
|
|
topMargin=72,
|
|
bottomMargin=72
|
|
)
|
|
|
|
# Container for PDF elements
|
|
elements = []
|
|
|
|
# Get styles
|
|
styles = getSampleStyleSheet()
|
|
header_style = styles["Heading1"]
|
|
subheader_style = styles["Heading2"]
|
|
normal_style = styles["Normal"]
|
|
|
|
# Custom styles
|
|
label_style = ParagraphStyle(
|
|
"label",
|
|
parent=normal_style,
|
|
fontName="Helvetica-Bold",
|
|
fontSize=10,
|
|
)
|
|
|
|
# Add invoice header
|
|
elements.append(Paragraph(f"INVOICE #{invoice.invoice_number}", header_style))
|
|
elements.append(Spacer(1, 0.25 * inch))
|
|
|
|
# Add organization and client information
|
|
org = invoice.organization
|
|
client = invoice.client
|
|
|
|
# Date information
|
|
date_data = [
|
|
[Paragraph("Issue Date:", label_style), Paragraph(invoice.issue_date.strftime("%Y-%m-%d"), normal_style)],
|
|
[Paragraph("Due Date:", label_style), Paragraph(invoice.due_date.strftime("%Y-%m-%d"), normal_style)],
|
|
[Paragraph("Status:", label_style), Paragraph(invoice.status.value.upper(), normal_style)],
|
|
]
|
|
date_table = Table(date_data, colWidths=[1.5 * inch, 2 * inch])
|
|
date_table.setStyle(TableStyle([
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
("ALIGN", (0, 0), (0, -1), "RIGHT"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 1),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
|
|
]))
|
|
|
|
# Organization information
|
|
org_data = [
|
|
[Paragraph("From:", label_style)],
|
|
[Paragraph(org.name, normal_style)],
|
|
]
|
|
|
|
if org.address:
|
|
org_data.append([Paragraph(org.address, normal_style)])
|
|
|
|
city_state_zip = []
|
|
if org.city:
|
|
city_state_zip.append(org.city)
|
|
if org.state:
|
|
city_state_zip.append(org.state)
|
|
if org.postal_code:
|
|
city_state_zip.append(org.postal_code)
|
|
|
|
if city_state_zip:
|
|
org_data.append([Paragraph(", ".join(city_state_zip), normal_style)])
|
|
|
|
if org.country:
|
|
org_data.append([Paragraph(org.country, normal_style)])
|
|
|
|
if org.phone:
|
|
org_data.append([Paragraph(f"Phone: {org.phone}", normal_style)])
|
|
|
|
if org.email:
|
|
org_data.append([Paragraph(f"Email: {org.email}", normal_style)])
|
|
|
|
if org.tax_id:
|
|
org_data.append([Paragraph(f"Tax ID: {org.tax_id}", normal_style)])
|
|
|
|
org_table = Table(org_data, colWidths=[3 * inch])
|
|
org_table.setStyle(TableStyle([
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 1),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
|
|
]))
|
|
|
|
# Client information
|
|
client_data = [
|
|
[Paragraph("Bill To:", label_style)],
|
|
[Paragraph(client.name, normal_style)],
|
|
]
|
|
|
|
if client.contact_name:
|
|
client_data.append([Paragraph(f"Attn: {client.contact_name}", normal_style)])
|
|
|
|
if client.address:
|
|
client_data.append([Paragraph(client.address, normal_style)])
|
|
|
|
client_city_state_zip = []
|
|
if client.city:
|
|
client_city_state_zip.append(client.city)
|
|
if client.state:
|
|
client_city_state_zip.append(client.state)
|
|
if client.postal_code:
|
|
client_city_state_zip.append(client.postal_code)
|
|
|
|
if client_city_state_zip:
|
|
client_data.append([Paragraph(", ".join(client_city_state_zip), normal_style)])
|
|
|
|
if client.country:
|
|
client_data.append([Paragraph(client.country, normal_style)])
|
|
|
|
if client.email:
|
|
client_data.append([Paragraph(f"Email: {client.email}", normal_style)])
|
|
|
|
if client.phone:
|
|
client_data.append([Paragraph(f"Phone: {client.phone}", normal_style)])
|
|
|
|
client_table = Table(client_data, colWidths=[3 * inch])
|
|
client_table.setStyle(TableStyle([
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 1),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
|
|
]))
|
|
|
|
# Combine the top tables
|
|
top_data = [[date_table, "", ""]]
|
|
top_table = Table(top_data, colWidths=[2.5 * inch, 0.5 * inch, 3 * inch])
|
|
elements.append(top_table)
|
|
elements.append(Spacer(1, 0.25 * inch))
|
|
|
|
# Combine organization and client tables
|
|
address_data = [[org_table, client_table]]
|
|
address_table = Table(address_data, colWidths=[3 * inch, 3 * inch])
|
|
address_table.setStyle(TableStyle([
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
]))
|
|
elements.append(address_table)
|
|
elements.append(Spacer(1, 0.5 * inch))
|
|
|
|
# Invoice items
|
|
elements.append(Paragraph("Invoice Items", subheader_style))
|
|
elements.append(Spacer(1, 0.25 * inch))
|
|
|
|
# Create data for the items table
|
|
items_data = [
|
|
[
|
|
Paragraph("Description", label_style),
|
|
Paragraph("Quantity", label_style),
|
|
Paragraph("Unit Price", label_style),
|
|
Paragraph("Amount", label_style),
|
|
]
|
|
]
|
|
|
|
for item in invoice.items:
|
|
items_data.append([
|
|
Paragraph(item.description, normal_style),
|
|
Paragraph(f"{item.quantity:.2f}", normal_style),
|
|
Paragraph(f"${item.unit_price:.2f}", normal_style),
|
|
Paragraph(f"${item.amount:.2f}", normal_style),
|
|
])
|
|
|
|
# Create the items table
|
|
items_table = Table(items_data, colWidths=[3.5 * inch, 1 * inch, 1 * inch, 1 * inch])
|
|
items_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
]))
|
|
elements.append(items_table)
|
|
elements.append(Spacer(1, 0.25 * inch))
|
|
|
|
# Summary table (subtotal, tax, discount, total)
|
|
summary_data = []
|
|
|
|
if invoice.subtotal:
|
|
summary_data.append([
|
|
"",
|
|
Paragraph("Subtotal:", label_style),
|
|
Paragraph(f"${invoice.subtotal:.2f}", normal_style),
|
|
])
|
|
|
|
if invoice.tax_rate:
|
|
summary_data.append([
|
|
"",
|
|
Paragraph(f"Tax ({invoice.tax_rate:.2f}%):", label_style),
|
|
Paragraph(f"${invoice.tax_amount:.2f}", normal_style),
|
|
])
|
|
|
|
if invoice.discount:
|
|
summary_data.append([
|
|
"",
|
|
Paragraph("Discount:", label_style),
|
|
Paragraph(f"${invoice.discount:.2f}", normal_style),
|
|
])
|
|
|
|
# Always include total
|
|
summary_data.append([
|
|
"",
|
|
Paragraph("Total:", ParagraphStyle(
|
|
"total",
|
|
parent=label_style,
|
|
fontSize=12,
|
|
)),
|
|
Paragraph(f"${invoice.total:.2f}", ParagraphStyle(
|
|
"total_amount",
|
|
parent=normal_style,
|
|
fontSize=12,
|
|
fontName="Helvetica-Bold",
|
|
)),
|
|
])
|
|
|
|
summary_table = Table(summary_data, colWidths=[3.5 * inch, 1.5 * inch, 1.5 * inch])
|
|
summary_table.setStyle(TableStyle([
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("ALIGN", (1, 0), (1, -1), "RIGHT"),
|
|
("ALIGN", (2, 0), (2, -1), "RIGHT"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 3),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
|
("LINEABOVE", (1, -1), (2, -1), 1, colors.black),
|
|
]))
|
|
elements.append(summary_table)
|
|
elements.append(Spacer(1, 0.5 * inch))
|
|
|
|
# Notes
|
|
if invoice.notes:
|
|
elements.append(Paragraph("Notes:", label_style))
|
|
elements.append(Paragraph(invoice.notes, normal_style))
|
|
elements.append(Spacer(1, 0.25 * inch))
|
|
|
|
# Terms
|
|
if invoice.terms:
|
|
elements.append(Paragraph("Terms and Conditions:", label_style))
|
|
elements.append(Paragraph(invoice.terms, normal_style))
|
|
|
|
# Build the PDF
|
|
doc.build(elements)
|
|
|
|
# Get the PDF content from the buffer
|
|
pdf_content = buffer.getvalue()
|
|
buffer.close()
|
|
|
|
return pdf_content |