initial commit

This commit is contained in:
2025-12-20 14:52:47 -06:00
commit 71397f0b8a
8 changed files with 1033 additions and 0 deletions

274
create_invoice.py Normal file
View File

@@ -0,0 +1,274 @@
import sqlite3
import os
from datetime import date, datetime
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
def connect_db():
"""
Connects to the SQLite database file and returns the connection object.
If the file does not exist, it prints an an error message.
"""
db_file = 'time_tracker.db'
if not os.path.exists(db_file):
print(f"Error: Database file '{db_file}' not found. Please run the database creation script first.")
return None
return sqlite3.connect(db_file)
def generate_invoice():
"""
Guides the user through selecting a client and month to generate a PDF invoice.
Calculates costs, creates the PDF, and updates the database.
"""
conn = connect_db()
if not conn:
return
try:
cursor = conn.cursor()
# Display active clients for selection
cursor.execute('SELECT client_id, client_name FROM clients WHERE active = 1 ORDER BY client_name')
active_clients = cursor.fetchall()
if not active_clients:
print("No active clients found to generate an invoice for.")
return
print("\n--- Select a Client to Invoice ---")
for client_id, client_name in active_clients:
print(f"{client_id}: {client_name}")
print("0: Exit")
print("----------------------------------\n")
while True:
try:
choice = input("Enter the ID of the client (or 0 to exit): ")
if choice == '0':
print("Exiting invoice generation.")
return
client_id = int(choice)
# Check if the entered ID is in the list of active clients
if any(c[0] == client_id for c in active_clients):
break
else:
print("Invalid client ID. Please enter a valid ID from the list.")
except ValueError:
print("Invalid input. Please enter a number.")
# Get the month from the user
month_year_str = input("Enter the month to invoice (YYYY-MM): ")
if len(month_year_str) != 7 or month_year_str[4] != '-':
print("Invalid date format. Please use YYYY-MM.")
return
# Fetch client details and billing rate
cursor.execute('SELECT * FROM clients WHERE client_id = ?', (client_id,))
client_data = cursor.fetchone()
client_name, billing_rate = client_data[1], client_data[7]
# Fetch time entries for the selected client and month
cursor.execute('''
SELECT date, hours, description, project, entry_id
FROM time_tracking
WHERE client_id = ? AND date LIKE ? AND invoiced = 0
ORDER BY date
''', (client_id, f"{month_year_str}%"))
time_entries = cursor.fetchall()
if not time_entries:
print("No new time entries found for this client and month.")
return
# Group entries by date and calculate daily totals
daily_summary = {}
for entry in time_entries:
entry_date = entry[0]
if entry_date not in daily_summary:
daily_summary[entry_date] = {'hours': 0.0, 'descriptions': [], 'projects': [], 'entry_ids': []}
daily_summary[entry_date]['hours'] += entry[1]
daily_summary[entry_date]['descriptions'].append(f"{entry[2]}")
daily_summary[entry_date]['projects'].append(f"{entry[3]}")
daily_summary[entry_date]['entry_ids'].append(entry[4])
# Prepare data for PDF and calculate invoice total
invoice_total = 0.0
# Reorder table headers as requested
data_for_pdf = [['Date', 'Description', 'Project', 'Hours', 'Rate', 'Amount']]
for daily_date, summary in daily_summary.items():
daily_hours = summary['hours']
daily_total = daily_hours * billing_rate
invoice_total += daily_total
# Join descriptions and projects with <br/> for ReportLab to create new lines
descriptions_str = "<br/>".join(summary['descriptions'])
projects_str = "<br/>".join(summary['projects'])
# Use Paragraph to handle multi-line descriptions and projects in the PDF
data_for_pdf.append([
daily_date,
Paragraph(descriptions_str, getSampleStyleSheet()['Normal']),
Paragraph(projects_str, getSampleStyleSheet()['Normal']),
f"{daily_hours:.2f}",
f"${billing_rate:.2f}",
f"${daily_total:.2f}"
])
# Display the invoice summary before creating the PDF
print("\n--- Invoice Summary ---")
for row in data_for_pdf[1:]: # Skip header row for print
print(f"Date: {row[0]}, Hours: {row[3]}, Total: {row[5]}")
print(f"\nTotal Invoice Amount: ${invoice_total:.2f}")
# Confirmation to create the PDF
confirm = input("Generate PDF and update database? (yes/no): ").lower()
if confirm not in ['yes', 'y']:
print("Invoice generation cancelled.")
return
# Get the desired save directory from the user with a default value
default_dir = os.path.expanduser("~/Documents/0 - Inbox")
save_dir = input(f"Enter directory to save PDF (default: {default_dir}): ")
if not save_dir:
save_dir = default_dir
# Ensure the directory exists
if not os.path.exists(save_dir):
os.makedirs(save_dir)
print(f"Created directory: {save_dir}")
# Generate unique invoice ID and full file path
invoice_id = datetime.now().strftime('%Y%m%d%H%M%S')
file_name = f"Invoice_{invoice_id}_{client_name.replace(' ', '_')}.pdf"
full_file_path = os.path.join(save_dir, file_name)
# Create PDF and update database
create_pdf(full_file_path, client_data, invoice_total, data_for_pdf, invoice_id)
# Update database
update_database_after_invoice(conn, cursor, time_entries, client_id, invoice_total)
print(f"\nInvoice successfully created at '{full_file_path}' and database has been updated.")
except sqlite3.Error as e:
print(f"An error occurred: {e}")
finally:
if conn:
conn.close()
def create_pdf(file_name, client_data, invoice_total, data_for_pdf, invoice_id):
"""
Creates the PDF document with the invoice details.
"""
doc = SimpleDocTemplate(file_name, pagesize=letter)
elements = []
# Define styles for the document
styles = getSampleStyleSheet()
styles.add(ParagraphStyle(name='InvoiceTitle', fontSize=24, fontName='Helvetica-Bold'))
styles.add(ParagraphStyle(name='ClientInfo', fontSize=12))
styles.add(ParagraphStyle(name='AmountDue', fontSize=12, alignment=2, fontName='Helvetica-Bold'))
today_date_str = date.today().strftime('%m/%d/%Y')
# Top header table (INVOICE and From)
header_top_table = Table([
[
Paragraph("INVOICE", styles['InvoiceTitle']),
Paragraph("<b>From:</b><br/>Matt Speer<br/>2313 Hunters Cove<br/>Vestavia Hills, AL 35216", styles['ClientInfo'])
]
], colWidths=[250, 250])
header_top_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('ALIGN', (1, 0), (1, 0), 'RIGHT')
]))
elements.append(header_top_table)
elements.append(Spacer(1, 20))
# Construct the client address string conditionally
client_address_str = f"<b>Invoice For:</b><br/>{client_data[1]}<br/>{client_data[2]}"
if client_data[3]: # Check if street_address_2 is not empty
client_address_str += f"<br/>{client_data[3]}"
client_address_str += f"<br/>{client_data[4]}, {client_data[5]} {client_data[6]}"
# Bottom header table (Invoice ID/Date and Invoice For)
header_bottom_table = Table([
[
Paragraph(f"Invoice ID: {invoice_id}<br/><br/>Invoice Date: {today_date_str}", styles['ClientInfo']),
Paragraph(client_address_str, styles['ClientInfo'])
]
], colWidths=[250, 250])
header_bottom_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('ALIGN', (1, 0), (1, 0), 'RIGHT')
]))
elements.append(header_bottom_table)
elements.append(Spacer(1, 20))
# Create the table for time entries
table = Table(data_for_pdf, colWidths=[80, 200, 80, 40, 50, 50])
table.setStyle(TableStyle([
('TEXTCOLOR', (0, 0), (-1, 0), colors.black),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('ALIGN', (0, 0), (-1, -0), 'LEFT'),
('VALIGN', (0, 0), (-1, 0), 'MIDDLE'), # Vertical align the header to the middle
('VALIGN', (0, 1), (-1, -1), 'TOP'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('LINEABOVE', (0, 0), (-1, 0), 1, colors.black),
('LINEBELOW', (0, 0), (-1, 0), 1, colors.black),
]))
elements.append(table)
elements.append(Spacer(1, 12))
# Line before the total amount due
line_table = Table([['']], colWidths=[500]) # Set width to match the time entries table
line_table.setStyle(TableStyle([
('LINEBELOW', (0, 0), (-1, -1), 1, colors.black),
('BOTTOMPADDING', (0, 0), (-1, -1), 0)
]))
elements.append(line_table)
elements.append(Spacer(1, 12))
# Add the total amount due section after the table
elements.append(Paragraph(f"AMOUNT DUE: ${invoice_total:.2f}", styles['AmountDue']))
# Build the document
doc.build(elements)
def update_database_after_invoice(conn, cursor, time_entries, client_id, invoice_total):
"""
Updates the 'invoiced' field for time entries and the client's balance.
"""
entry_ids = [entry[4] for entry in time_entries]
# Update time entries to be invoiced
cursor.executemany('UPDATE time_tracking SET invoiced = 1 WHERE entry_id = ?', [(entry_id,) for entry_id in entry_ids])
# Update the client's balance
cursor.execute('SELECT balance FROM clients WHERE client_id = ?', (client_id,))
current_balance = cursor.fetchone()[0]
new_balance = current_balance - invoice_total
cursor.execute('UPDATE clients SET balance = ? WHERE client_id = ?', (new_balance, client_id))
conn.commit()
if __name__ == "__main__":
generate_invoice()