Files
2026-05-09 09:42:43 -05:00

217 lines
8.1 KiB
Python

import sys
import os
from datetime import datetime, timedelta
from fpdf import FPDF
from utils import load_config, load_json, save_json
class InvoicePDF(FPDF):
def __init__(self, is_recreated=False):
super().__init__(orientation='P', unit='mm', format='A4')
self.is_recreated = is_recreated
# Table column widths[cite: 1]
self.w_date, self.w_desc, self.w_proj, self.w_hrs, self.w_rate, self.w_total = 25, 80, 25, 15, 20, 25
def header(self):
# Main Header[cite: 1]
self.set_font("helvetica", "B", 28)
self.cell(100, 20, "INVOICE", 0, 0, "L")
self.ln(10)
# Repeat table headers on every page if we are currently in the table section[cite: 1]
if self.page_no() > 1:
self.draw_table_header()
def draw_table_header(self):
"""Standardized table header that can be called on new pages[cite: 1]."""
self.ln(5)
self.set_font("helvetica", "B", 10)
self.cell(self.w_date, 8, "Date", 0)
self.cell(self.w_desc, 8, "Description", 0)
self.cell(self.w_proj, 8, "Project", 0)
self.cell(self.w_hrs, 8, "Hours", 0, 0, "R")
self.cell(self.w_rate, 8, "Rate", 0, 0, "R")
self.cell(self.w_total, 8, "Amount", 0, 1, "R")
self.line(10, self.get_y(), 200, self.get_y())
self.ln(2)
self.set_font("helvetica", "", 9)
def footer(self):
self.set_y(-15)
self.set_font("helvetica", "I", 8)
self.cell(0, 10, f"Page {self.page_no()}", 0, 0, "R")
def select_item(items, label):
print(f"\n--- Select {label} ---")
for i, item in enumerate(items, 1):
print(f"{i}. {item['Name']}")
while True:
try:
choice = input(f"Enter # for {label} (or Enter for all): ")
if not choice: return None
idx = int(choice)
if 1 <= idx <= len(items):
return items[idx - 1]
except ValueError:
pass
print("Invalid selection.")
def main():
config = load_config()
data_dir = config["DataDirectory"]
clients_data = load_json(os.path.join(data_dir, "Clients.json"), {"Clients": []})
projects_data = load_json(os.path.join(data_dir, "Projects.json"), {"Projects": []})
active_clients = [c for c in clients_data["Clients"] if c.get("Active", True)]
client = select_item(active_clients, "Client")
if not client: return
client_id = client["ClientID"]
print("\n--- Invoice Mode ---")
print("1. Standard (Last Month)")
print("2. Custom (Range/Filter)")
print("3. Re-create")
mode = input("Select mode: ")
start_date, end_date, filter_pid = None, None, None
if mode == "2":
start_date = input("Start Date (YYYY-MM-DD): ")
end_date = input("End Date (YYYY-MM-DD): ")
client_projs = [p for p in projects_data["Projects"] if p["ClientID"] == client_id]
if client_projs:
p_choice = select_item(client_projs, "Project Filter")
if p_choice: filter_pid = p_choice["ProjectID"]
elif mode == "1":
today = datetime.now()
last_month = today.replace(day=1) - timedelta(days=1)
start_date = last_month.replace(day=1).strftime("%Y-%m-%d")
end_date = last_month.strftime("%Y-%m-%d")
aggregated = {}
log_files = [f for f in os.listdir(data_dir) if f.endswith("_Time_Log.json")]
invoice_id = datetime.now().strftime("%Y%m%d%H%M%S")
files_to_update = {}
for file_name in log_files:
full_path = os.path.join(data_dir, file_name)
logs = load_json(full_path, {"Entries": []})
modified = False
for entry in logs.get("Entries", []):
if entry["ClientID"] != client_id: continue
if mode != "3" and entry.get("Invoiced", False): continue
if start_date and entry["Date"] < start_date: continue
if end_date and entry["Date"] > end_date: continue
if filter_pid and entry["ProjectID"] != filter_pid: continue
if mode != "3":
entry["Invoiced"] = True
modified = True
key = (entry["Date"], entry["ProjectID"])
rate = client["DefaultRate"]
p_name = ""
if entry["ProjectID"]:
p = next((x for x in projects_data["Projects"] if x["ProjectID"] == entry["ProjectID"]), None)
if p:
p_name = p.get("Code") or p["Name"]
if p.get("BillingRate", 0) > 0: rate = p["BillingRate"]
if key not in aggregated:
aggregated[key] = {"Date": entry["Date"], "Project": p_name, "Hours": 0.0, "Desc": [], "Rate": rate}
aggregated[key]["Hours"] += float(entry.get("Duration", 0))
aggregated[key]["Desc"].append(entry["Description"])
if modified: files_to_update[full_path] = logs
if not aggregated:
print("No billable entries found.")
return
pdf = InvoicePDF(is_recreated=(mode == "3"))
pdf.add_page()
# 1. Contact Information and Metadata[cite: 1]
align_x = 130
pdf.set_font("helvetica", "B", 11)
pdf.set_xy(align_x, 10)
pdf.cell(0, 5, "From:", 0, 1)
pdf.set_font("helvetica", "", 10)
pdf.set_x(align_x)
pdf.multi_cell(70, 5, "Matt Speer\n2313 Hunters Cove\nVestavia Hills, AL 35216")
pdf.ln(10)
pdf.set_font("helvetica", "", 10)
meta_y = pdf.get_y()
pdf.text(10, meta_y, f"Invoice ID: {invoice_id}")
pdf.text(10, meta_y + 8, f"Invoice Date: {datetime.now().strftime('%m/%d/%Y')}")
pdf.set_xy(align_x, meta_y - 4)
pdf.set_font("helvetica", "B", 11)
pdf.cell(0, 5, "Invoice For:", 0, 1)
pdf.set_font("helvetica", "", 10)
pdf.set_x(align_x)
addr = client['BillingAddress']
pdf.multi_cell(70, 5, f"{client['Name']}\n{addr['Street1']}\n{addr['City']}, {addr['State']} {addr['PostalCode']}")
# 2. Initial Table Header[cite: 1]
pdf.ln(15)
pdf.draw_table_header()
# 3. Table Rows with TOP ALIGNMENT[cite: 1]
pdf.set_font("helvetica", "", 9)
grand_total = 0
for key in sorted(aggregated.keys()):
row = aggregated[key]
line_total = row["Hours"] * row["Rate"]
grand_total += line_total
full_desc = "\n".join(row["Desc"])
# Calculate height required for the description block[cite: 1]
line_h = 5
lines = pdf.multi_cell(pdf.w_desc, line_h, full_desc, split_only=True)
row_h = max(8, len(lines) * line_h)
# Check for page break[cite: 1]
if pdf.get_y() + row_h > 270:
pdf.add_page()
# Header is automatically drawn by pdf.header() logic[cite: 1]
start_y = pdf.get_y()
# Date (Top Aligned)[cite: 1]
pdf.set_y(start_y)
pdf.cell(pdf.w_date, line_h, row["Date"], 0)
# Description (Multi-line, Top Aligned)[cite: 1]
pdf.set_xy(10 + pdf.w_date, start_y)
pdf.multi_cell(pdf.w_desc, line_h, full_desc, 0)
# Project, Hours, Rate, Amount (Top Aligned relative to the description start)[cite: 1]
pdf.set_xy(10 + pdf.w_date + pdf.w_desc, start_y)
pdf.cell(pdf.w_proj, line_h, row["Project"], 0)
pdf.cell(pdf.w_hrs, line_h, f"{row['Hours']:.2f}", 0, 0, "R")
pdf.cell(pdf.w_rate, line_h, f"${row['Rate']:.2f}", 0, 0, "R")
pdf.cell(pdf.w_total, line_h, f"${line_total:.2f}", 0, 1, "R")
# Reset Y to the bottom of the current multi-line row before next entry[cite: 1]
pdf.set_y(start_y + row_h + 2)
# 4. Grand Total[cite: 1]
pdf.ln(5)
if pdf.get_y() > 270: pdf.add_page()
pdf.line(150, pdf.get_y(), 200, pdf.get_y())
pdf.set_font("helvetica", "B", 11)
pdf.cell(pdf.w_date + pdf.w_desc + pdf.w_proj + pdf.w_hrs + pdf.w_rate, 10, "Total Amount Due:", 0, 0, "R")
pdf.cell(pdf.w_total, 10, f"${grand_total:.2f}", 0, 1, "R")
out_path = os.path.join(config["PDFOutputDirectory"], f"Invoice_{invoice_id}.pdf")
pdf.output(out_path)
if mode != "3":
for path, data in files_to_update.items():
save_json(path, data)
print(f"Generated: {out_path}")
if __name__ == "__main__":
main()