invoicing app 1
# Complete Flask Invoicing Web Application
# Based on comprehensive market research for beginner-friendly design
import os
import re
import smtplib
from datetime import datetime, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from functools import wraps
import secrets
import logging
from io import BytesIO
import base64
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, session, send_file, abort
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from werkzeug.security import generate_password_hash, check_password_hash
from reportlab.lib.pagesizes import letter, A4
from reportlab.pdfgen import canvas
from reportlab.lib.units import inch
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_RIGHT, TA_LEFT
# Initialize Flask app with security configurations
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', secrets.token_hex(32))
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///invoicing.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Email configuration for automated invoice sending
app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 587))
app.config['MAIL_USE_TLS'] = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME')
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD')
# Initialize extensions
db = SQLAlchemy(app)
bcrypt = Bcrypt(app)
# Configure logging for better error tracking
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Database Models with relationships for comprehensive invoicing
class User(db.Model):
"""User authentication model with secure password hashing"""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
company_name = db.Column(db.String(200), nullable=True)
company_address = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
invoices = db.relationship('Invoice', backref='user', lazy=True, cascade='all, delete-orphan')
clients = db.relationship('Client', backref='user', lazy=True, cascade='all, delete-orphan')
def set_password(self, password):
"""Secure password hashing using bcrypt"""
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
"""Verify password against stored hash"""
return bcrypt.check_password_hash(self.password_hash, password)
class Client(db.Model):
"""Client management model for CRM integration"""
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
name = db.Column(db.String(200), nullable=False)
email = db.Column(db.String(120), nullable=False)
phone = db.Column(db.String(20), nullable=True)
address = db.Column(db.Text, nullable=True)
company = db.Column(db.String(200), nullable=True)
vat_number = db.Column(db.String(50), nullable=True) # EU compliance requirement
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
invoices = db.relationship('Invoice', backref='client', lazy=True)
class Invoice(db.Model):
"""Core invoice model with sequential numbering and status tracking"""
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
client_id = db.Column(db.Integer, db.ForeignKey('client.id'), nullable=False)
# Sequential numbering for EU compliance
invoice_number = db.Column(db.String(50), unique=True, nullable=False)
# Invoice details
issue_date = db.Column(db.Date, default=datetime.utcnow().date)
due_date = db.Column(db.Date, nullable=False)
# Financial calculations
subtotal = db.Column(db.Numeric(10, 2), default=0.00)
tax_rate = db.Column(db.Numeric(5, 2), default=0.00)
tax_amount = db.Column(db.Numeric(10, 2), default=0.00)
discount_rate = db.Column(db.Numeric(5, 2), default=0.00)
discount_amount = db.Column(db.Numeric(10, 2), default=0.00)
total_amount = db.Column(db.Numeric(10, 2), default=0.00)
# Status tracking for payment management
status = db.Column(db.String(20), default='draft') # draft, sent, paid, overdue
payment_date = db.Column(db.Date, nullable=True)
payment_method = db.Column(db.String(50), nullable=True)
# Notes and terms
notes = db.Column(db.Text, nullable=True)
payment_terms = db.Column(db.String(200), default='Net 30')
# Timestamps
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
sent_at = db.Column(db.DateTime, nullable=True)
# Relationships
items = db.relationship('InvoiceItem', backref='invoice', lazy=True, cascade='all, delete-orphan')
class InvoiceItem(db.Model):
"""Invoice line items with flexible pricing structure"""
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False)
description = db.Column(db.String(500), nullable=False)
quantity = db.Column(db.Numeric(10, 2), default=1.00)
unit_price = db.Column(db.Numeric(10, 2), nullable=False)
line_total = db.Column(db.Numeric(10, 2), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Authentication decorator for secure access control
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Please log in to access this page.', 'warning')
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
# Utility Functions
def generate_invoice_number():
"""Generate sequential invoice number with year prefix (EU compliance)"""
year = datetime.now().year
last_invoice = Invoice.query.filter(
Invoice.invoice_number.like(f'{year}-%')
).order_by(Invoice.invoice_number.desc()).first()
if last_invoice:
try:
last_num = int(last_invoice.invoice_number.split('-')[1])
next_num = last_num + 1
except (IndexError, ValueError):
next_num = 1
else:
next_num = 1
return f"{year}-{next_num:04d}"
def calculate_invoice_totals(items_data):
"""Calculate invoice totals with tax and discount logic"""
subtotal = sum(float(item['quantity']) * float(item['unit_price']) for item in items_data)
return subtotal
def send_invoice_email(invoice, pdf_data):
"""Automated email sending with PDF attachment"""
try:
msg = MIMEMultipart()
msg['From'] = app.config['MAIL_USERNAME']
msg['To'] = invoice.client.email
msg['Subject'] = f'Invoice {invoice.invoice_number} from {invoice.user.company_name or invoice.user.username}'
# Email body with professional template
body = f"""
Dear {invoice.client.name},
Please find attached your invoice #{invoice.invoice_number} for ${invoice.total_amount}.
Invoice Details:
- Issue Date: {invoice.issue_date.strftime('%B %d, %Y')}
- Due Date: {invoice.due_date.strftime('%B %d, %Y')}
- Amount: ${invoice.total_amount}
- Payment Terms: {invoice.payment_terms}
{invoice.notes or ''}
Thank you for your business!
Best regards,
{invoice.user.company_name or invoice.user.username}
"""
msg.attach(MIMEText(body, 'plain'))
# Attach PDF
pdf_attachment = MIMEApplication(pdf_data, _subtype='pdf')
pdf_attachment.add_header('Content-Disposition', 'attachment', filename=f'invoice_{invoice.invoice_number}.pdf')
msg.attach(pdf_attachment)
# Send email
with smtplib.SMTP(app.config['MAIL_SERVER'], app.config['MAIL_PORT']) as server:
server.starttls()
server.login(app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD'])
server.send_message(msg)
return True
except Exception as e:
logger.error(f"Email sending failed: {str(e)}")
return False
def generate_invoice_pdf(invoice):
"""Generate professional PDF invoice with branding"""
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=A4)
elements = []
styles = getSampleStyleSheet()
# Custom styles for professional appearance
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
textColor=colors.HexColor('#2c3e50'),
spaceAfter=30,
alignment=TA_CENTER
)
header_style = ParagraphStyle(
'CustomHeader',
parent=styles['Normal'],
fontSize=12,
textColor=colors.HexColor('#34495e'),
spaceAfter=12
)
# Invoice header
elements.append(Paragraph("INVOICE", title_style))
elements.append(Spacer(1, 20))
# Company and client info table
info_data = [
['From:', 'To:'],
[f"{invoice.user.company_name or invoice.user.username}", invoice.client.name],
[invoice.user.company_address or 'Address not set', f"{invoice.client.company or ''}"],
['', invoice.client.address or ''],
['', f"Email: {invoice.client.email}"],
]
info_table = Table(info_data, colWidths=[3*inch, 3*inch])
info_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
]))
elements.append(info_table)
elements.append(Spacer(1, 20))
# Invoice details
details_data = [
['Invoice Number:', invoice.invoice_number],
['Issue Date:', invoice.issue_date.strftime('%B %d, %Y')],
['Due Date:', invoice.due_date.strftime('%B %d, %Y')],
['Payment Terms:', invoice.payment_terms],
]
details_table = Table(details_data, colWidths=[2*inch, 2*inch])
details_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
elements.append(details_table)
elements.append(Spacer(1, 30))
# Invoice items table
items_data = [['Description', 'Quantity', 'Unit Price', 'Total']]
for item in invoice.items:
items_data.append([
item.description,
str(item.quantity),
f"${item.unit_price:.2f}",
f"${item.line_total:.2f}"
])
items_table = Table(items_data, colWidths=[3*inch, 1*inch, 1.5*inch, 1.5*inch])
items_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
('GRID', (0, 0), (-1, -1), 1, colors.black)
]))
elements.append(items_table)
elements.append(Spacer(1, 30))
# Totals table
totals_data = [
['Subtotal:', f"${invoice.subtotal:.2f}"],
[f'Discount ({invoice.discount_rate}%):', f"-${invoice.discount_amount:.2f}"],
[f'Tax ({invoice.tax_rate}%):', f"${invoice.tax_amount:.2f}"],
['Total Amount:', f"${invoice.total_amount:.2f}"]
]
totals_table = Table(totals_data, colWidths=[2*inch, 1.5*inch], hAlign='RIGHT')
totals_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'RIGHT'),
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#e8f4fd')),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
elements.append(totals_table)
# Notes section
if invoice.notes:
elements.append(Spacer(1, 30))
elements.append(Paragraph("Notes:", header_style))
elements.append(Paragraph(invoice.notes, styles['Normal']))
doc.build(elements)
buffer.seek(0)
return buffer.getvalue()
# Routes
@app.route('/')
@login_required
def dashboard():
"""Modern dashboard with key metrics and analytics"""
user = User.query.get(session['user_id'])
# Calculate dashboard metrics
total_invoices = Invoice.query.filter_by(user_id=user.id).count()
paid_invoices = Invoice.query.filter_by(user_id=user.id, status='paid').count()
pending_invoices = Invoice.query.filter_by(user_id=user.id, status='sent').count()
overdue_invoices = Invoice.query.filter(
Invoice.user_id == user.id,
Invoice.status == 'sent',
Invoice.due_date < datetime.utcnow().date()
).count()
total_revenue = db.session.query(db.func.sum(Invoice.total_amount)).filter(
Invoice.user_id == user.id,
Invoice.status == 'paid'
).scalar() or 0
pending_amount = db.session.query(db.func.sum(Invoice.total_amount)).filter(
Invoice.user_id == user.id,
Invoice.status == 'sent'
).scalar() or 0
# Recent invoices for quick access
recent_invoices = Invoice.query.filter_by(user_id=user.id).order_by(
Invoice.created_at.desc()
).limit(5).all()
# Monthly revenue data for charts
monthly_data = db.session.query(
db.func.strftime('%Y-%m', Invoice.issue_date).label('month'),
db.func.sum(Invoice.total_amount).label('revenue')
).filter(
Invoice.user_id == user.id,
Invoice.status == 'paid'
).group_by('month').order_by('month').limit(12).all()
return render_template('dashboard.html',
user=user,
total_invoices=total_invoices,
paid_invoices=paid_invoices,
pending_invoices=pending_invoices,
overdue_invoices=overdue_invoices,
total_revenue=float(total_revenue),
pending_amount=float(pending_amount),
recent_invoices=recent_invoices,
monthly_data=monthly_data)
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Secure login with bcrypt password verification"""
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
session['user_id'] = user.id
session['username'] = user.username
flash('Login successful!', 'success')
return redirect(url_for('dashboard'))
else:
flash('Invalid username or password.', 'danger')
return render_template('auth/login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
"""User registration with input validation"""
if request.method == 'POST':
username = request.form['username'].strip()
email = request.form['email'].strip().lower()
password = request.form['password']
company_name = request.form.get('company_name', '').strip()
company_address = request.form.get('company_address', '').strip()
# Input validation
if len(username) < 3:
flash('Username must be at least 3 characters long.', 'danger')
return render_template('auth/register.html')
if len(password) < 6:
flash('Password must be at least 6 characters long.', 'danger')
return render_template('auth/register.html')
# Email format validation
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
flash('Please enter a valid email address.', 'danger')
return render_template('auth/register.html')
# Check if user already exists
if User.query.filter_by(username=username).first():
flash('Username already exists.', 'danger')
return render_template('auth/register.html')
if User.query.filter_by(email=email).first():
flash('Email already registered.', 'danger')
return render_template('auth/register.html')
# Create new user
user = User(
username=username,
email=email,
company_name=company_name,
company_address=company_address
)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('Registration successful! Please log in.', 'success')
return redirect(url_for('login'))
return render_template('auth/register.html')
@app.route('/logout')
def logout():
"""Secure session cleanup"""
session.clear()
flash('You have been logged out successfully.', 'info')
return redirect(url_for('login'))
@app.route('/clients')
@login_required
def clients():
"""Client management with search functionality"""
search_query = request.args.get('search', '')
page = request.args.get('page', 1, type=int)
query = Client.query.filter_by(user_id=session['user_id'])
if search_query:
query = query.filter(
db.or_(
Client.name.contains(search_query),
Client.email.contains(search_query),
Client.company.contains(search_query)
)
)
clients = query.order_by(Client.created_at.desc()).paginate(
page=page, per_page=10, error_out=False
)
return render_template('clients/list.html', clients=clients, search_query=search_query)
@app.route('/clients/add', methods=['GET', 'POST'])
@login_required
def add_client():
"""Add new client with validation"""
if request.method == 'POST':
name = request.form['name'].strip()
email = request.form['email'].strip().lower()
phone = request.form.get('phone', '').strip()
address = request.form.get('address', '').strip()
company = request.form.get('company', '').strip()
vat_number = request.form.get('vat_number', '').strip()
# Validation
if not name or not email:
flash('Name and email are required.', 'danger')
return render_template('clients/form.html')
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
flash('Please enter a valid email address.', 'danger')
return render_template('clients/form.html')
# Check for duplicate email within user's clients
existing_client = Client.query.filter_by(
user_id=session['user_id'],
email=email
).first()
if existing_client:
flash('A client with this email already exists.', 'danger')
return render_template('clients/form.html')
client = Client(
user_id=session['user_id'],
name=name,
email=email,
phone=phone,
address=address,
company=company,
vat_number=vat_number
)
db.session.add(client)
db.session.commit()
flash(f'Client {name} added successfully!', 'success')
return redirect(url_for('clients'))
return render_template('clients/form.html')
@app.route('/clients//edit', methods=['GET', 'POST'])
@login_required
def edit_client(client_id):
"""Edit existing client"""
client = Client.query.filter_by(id=client_id, user_id=session['user_id']).first_or_404()
if request.method == 'POST':
client.name = request.form['name'].strip()
client.email = request.form['email'].strip().lower()
client.phone = request.form.get('phone', '').strip()
client.address = request.form.get('address', '').strip()
client.company = request.form.get('company', '').strip()
client.vat_number = request.form.get('vat_number', '').strip()
client.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Client {client.name} updated successfully!', 'success')
return redirect(url_for('clients'))
return render_template('clients/form.html', client=client)
@app.route('/clients//delete', methods=['POST'])
@login_required
def delete_client(client_id):
"""Delete client with confirmation"""
client = Client.query.filter_by(id=client_id, user_id=session['user_id']).first_or_404()
# Check if client has invoices
if client.invoices:
flash(f'Cannot delete client {client.name}. They have existing invoices.', 'danger')
return redirect(url_for('clients'))
db.session.delete(client)
db.session.commit()
flash(f'Client {client.name} deleted successfully!', 'success')
return redirect(url_for('clients'))
@app.route('/invoices')
@login_required
def invoices():
"""Invoice list with advanced filtering"""
status_filter = request.args.get('status', '')
search_query = request.args.get('search', '')
page = request.args.get('page', 1, type=int)
query = Invoice.query.filter_by(user_id=session['user_id'])
if status_filter:
query = query.filter(Invoice.status == status_filter)
if search_query:
query = query.join(Client).filter(
db.or_(
Invoice.invoice_number.contains(search_query),
Client.name.contains(search_query),
Client.company.contains(search_query)
)
)
invoices = query.order_by(Invoice.created_at.desc()).paginate(
page=page, per_page=15, error_out=False
)
return render_template('invoices/list.html',
invoices=invoices,
status_filter=status_filter,
search_query=search_query)
@app.route('/invoices/create', methods=['GET', 'POST'])
@login_required
def create_invoice():
"""Create new invoice with automated calculations"""
if request.method == 'POST':
try:
# Basic invoice data
client_id = int(request.form['client_id'])
due_date = datetime.strptime(request.form['due_date'], '%Y-%m-%d').date()
tax_rate = float(request.form.get('tax_rate', 0))
discount_rate = float(request.form.get('discount_rate', 0))
notes = request.form.get('notes', '').strip()
payment_terms = request.form.get('payment_terms', 'Net 30').strip()
# Validate client ownership
client = Client.query.filter_by(id=client_id, user_id=session['user_id']).first()
if not client:
flash('Invalid client selected.', 'danger')
return redirect(url_for('create_invoice'))
# Process invoice items
items_data = []
item_count = int(request.form.get('item_count', 0))
for i in range(item_count):
description = request.form.get(f'item_description_{i}', '').strip()
quantity = float(request.form.get(f'item_quantity_{i}', 0))
unit_price = float(request.form.get(f'item_price_{i}', 0))
if description and quantity > 0 and unit_price > 0:
line_total = quantity * unit_price
items_data.append({
'description': description,
'quantity': quantity,
'unit_price': unit_price,
'line_total': line_total
})
if not items_data:
flash('Please add at least one invoice item.', 'danger')
return redirect(url_for('create_invoice'))
# Calculate totals
subtotal = sum(item['line_total'] for item in items_data)
discount_amount = subtotal * (discount_rate / 100)
subtotal_after_discount = subtotal - discount_amount
tax_amount = subtotal_after_discount * (tax_rate / 100)
total_amount = subtotal_after_discount + tax_amount
# Create invoice
invoice = Invoice(
user_id=session['user_id'],
client_id=client_id,
invoice_number=generate_invoice_number(),
due_date=due_date,
subtotal=subtotal,
tax_rate=tax_rate,
tax_amount=tax_amount,
discount_rate=discount_rate,
discount_amount=discount_amount,
total_amount=total_amount,
notes=notes,
payment_terms=payment_terms
)
db.session.add(invoice)
db.session.flush() # Get invoice ID
# Add invoice items
for item_data in items_data:
item = InvoiceItem(
invoice_id=invoice.id,
description=item_data['description'],
quantity=item_data['quantity'],
unit_price=item_data['unit_price'],
line_total=item_data['line_total']
)
db.session.add(item)
db.session.commit()
flash(f'Invoice {invoice.invoice_number} created successfully!', 'success')
return redirect(url_for('view_invoice', invoice_id=invoice.id))
except Exception as e:
db.session.rollback()
logger.error(f"Invoice creation error: {str(e)}")
flash('An error occurred while creating the invoice. Please try again.', 'danger')
return redirect(url_for('create_invoice'))
# Get clients for dropdown
clients = Client.query.filter_by(user_id=session['user_id']).order_by(Client.name).all()
return render_template('invoices/form.html', clients=clients)
@app.route('/invoices/')
@login_required
def view_invoice(invoice_id):
"""View invoice details with action buttons"""
invoice = Invoice.query.filter_by(id=invoice_id, user_id=session['user_id']).first_or_404()
return render_template('invoices/view.html', invoice=invoice)
@app.route('/invoices//edit', methods=['GET', 'POST'])
@login_required
def edit_invoice(invoice_id):
"""Edit existing invoice (only if not paid)"""
invoice = Invoice.query.filter_by(id=invoice_id, user_id=session['user_id']).first_or_404()
if invoice.status == 'paid':
flash('Cannot edit a paid invoice.', 'warning')
return redirect(url_for('view_invoice', invoice_id=invoice.id))
if request.method == 'POST':
try:
# Update invoice details
invoice.client_id = int(request.form['client_id'])
invoice.due_date = datetime.strptime(request.form['due_date'], '%Y-%m-%d').date()
invoice.tax_rate = float(request.form.get('tax_rate', 0))
invoice.discount_rate = float(request.form.get('discount_rate', 0))
invoice.notes = request.form.get('notes', '').strip()
invoice.payment_terms = request.form.get('payment_terms', 'Net 30').strip()
# Clear existing items
InvoiceItem.query.filter_by(invoice_id=invoice.id).delete()
# Process updated items
items_data = []
item_count = int(request.form.get('item_count', 0))
for i in range(item_count):
description = request.form.get(f'item_description_{i}', '').strip()
quantity = float(request.form.get(f'item_quantity_{i}', 0))
unit_price = float(request.form.get(f'item_price_{i}', 0))
if description and quantity > 0 and unit_price > 0:
line_total = quantity * unit_price
item = InvoiceItem(
invoice_id=invoice.id,
description=description,
quantity=quantity,
unit_price=unit_price,
line_total=line_total
)
db.session.add(item)
items_data.append(item)
# Recalculate totals
subtotal = sum(item.line_total for item in items_data)
discount_amount = subtotal * (invoice.discount_rate / 100)
subtotal_after_discount = subtotal - discount_amount
tax_amount = subtotal_after_discount * (invoice.tax_rate / 100)
total_amount = subtotal_after_discount + tax_amount
invoice.subtotal = subtotal
invoice.discount_amount = discount_amount
invoice.tax_amount = tax_amount
invoice.total_amount = total_amount
invoice.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Invoice {invoice.invoice_number} updated successfully!', 'success')
return redirect(url_for('view_invoice', invoice_id=invoice.id))
except Exception as e:
db.session.rollback()
logger.error(f"Invoice update error: {str(e)}")
flash('An error occurred while updating the invoice.', 'danger')
clients = Client.query.filter_by(user_id=session['user_id']).order_by(Client.name).all()
return render_template('invoices/form.html', invoice=invoice, clients=clients)
@app.route('/invoices//delete', methods=['POST'])
@login_required
def delete_invoice(invoice_id):
"""Delete invoice with confirmation"""
invoice = Invoice.query.filter_by(id=invoice_id, user_id=session['user_id']).first_or_404()
if invoice.status == 'paid':
flash('Cannot delete a paid invoice.', 'warning')
return redirect(url_for('invoices'))
db.session.delete(invoice)
db.session.commit()
flash(f'Invoice {invoice.invoice_number} deleted successfully!', 'success')
return redirect(url_for('invoices'))
@app.route('/invoices//send', methods=['POST'])
@login_required
def send_invoice(invoice_id):
"""Send invoice via email with PDF attachment"""
invoice = Invoice.query.filter_by(id=invoice_id, user_id=session['user_id']).first_or_404()
try:
# Generate PDF
pdf_data = generate_invoice_pdf(invoice)
# Send email
if send_invoice_email(invoice, pdf_data):
invoice.status = 'sent'
invoice.sent_at = datetime.utcnow()
db.session.commit()
flash(f'Invoice {invoice.invoice_number} sent successfully to {invoice.client.email}!', 'success')
else:
flash('Failed to send invoice. Please check email configuration.', 'danger')
except Exception as e:
logger.error(f"Invoice sending error: {str(e)}")
flash('An error occurred while sending the invoice.', 'danger')
return redirect(url_for('view_invoice', invoice_id=invoice.id))
@app.route('/invoices//pdf')
@login_required
def download_pdf(invoice_id):
"""Download invoice as PDF"""
invoice = Invoice.query.filter_by(id=invoice_id, user_id=session['user_id']).first_or_404()
try:
pdf_data = generate_invoice_pdf(invoice)
return send_file(
BytesIO(pdf_data),
as_attachment=True,
download_name=f'invoice_{invoice.invoice_number}.pdf',
mimetype='application/pdf'
)
except Exception as e:
logger.error(f"PDF generation error: {str(e)}")
flash('An error occurred while generating the PDF.', 'danger')
return redirect(url_for('view_invoice', invoice_id=invoice.id))
@app.route('/invoices//mark_paid', methods=['POST'])
@login_required
def mark_paid(invoice_id):
"""Mark invoice as paid with payment details"""
invoice = Invoice.query.filter_by(id=invoice_id, user_id=session['user_id']).first_or_404()
payment_method = request.form.get('payment_method', 'Cash')
payment_date = request.form.get('payment_date')
if payment_date:
try:
invoice.payment_date = datetime.strptime(payment_date, '%Y-%m-%d').date()
except ValueError:
invoice.payment_date = datetime.utcnow().date()
else:
invoice.payment_date = datetime.utcnow().date()
invoice.payment_method = payment_method
invoice.status = 'paid'
invoice.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Invoice {invoice.invoice_number} marked as paid!', 'success')
return redirect(url_for('view_invoice', invoice_id=invoice.id))
@app.route('/invoices//mark_unpaid', methods=['POST'])
@login_required
def mark_unpaid(invoice_id):
"""Mark invoice as unpaid (revert payment)"""
invoice = Invoice.query.filter_by(id=invoice_id, user_id=session['user_id']).first_or_404()
invoice.status = 'sent' if invoice.sent_at else 'draft'
invoice.payment_date = None
invoice.payment_method = None
invoice.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Invoice {invoice.invoice_number} marked as unpaid!', 'success')
return redirect(url_for('view_invoice', invoice_id=invoice.id))
@app.route('/reports')
@login_required
def reports():
"""Financial reports and analytics"""
# Date range filters
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
# Base query
query = Invoice.query.filter_by(user_id=session['user_id'])
if start_date:
query = query.filter(Invoice.issue_date >= datetime.strptime(start_date, '%Y-%m-%d').date())
if end_date:
query = query.filter(Invoice.issue_date <= datetime.strptime(end_date, '%Y-%m-%d').date())
# Revenue by status
revenue_by_status = db.session.query(
Invoice.status,
db.func.count(Invoice.id).label('count'),
db.func.sum(Invoice.total_amount).label('total')
).filter(
Invoice.user_id == session['user_id'],
Invoice.issue_date >= datetime.strptime(start_date, '%Y-%m-%d').date(),
Invoice.issue_date <= datetime.strptime(end_date, '%Y-%m-%d').date()
).group_by(Invoice.status).all()
# Top clients
top_clients = db.session.query(
Client.name,
db.func.count(Invoice.id).label('invoice_count'),
db.func.sum(Invoice.total_amount).label('total_revenue')
).join(Invoice).filter(
Invoice.user_id == session['user_id'],
Invoice.issue_date >= datetime.strptime(start_date, '%Y-%m-%d').date(),
Invoice.issue_date <= datetime.strptime(end_date, '%Y-%m-%d').date()
).group_by(Client.id, Client.name).order_by(
db.func.sum(Invoice.total_amount).desc()
).limit(10).all()
# Monthly revenue trend
monthly_revenue = db.session.query(
db.func.strftime('%Y-%m', Invoice.issue_date).label('month'),
db.func.sum(Invoice.total_amount).label('revenue'),
db.func.count(Invoice.id).label('invoice_count')
).filter(
Invoice.user_id == session['user_id'],
Invoice.status == 'paid',
Invoice.issue_date >= datetime.strptime(start_date, '%Y-%m-%d').date(),
Invoice.issue_date <= datetime.strptime(end_date, '%Y-%m-%d').date()
).group_by('month').order_by('month').all()
return render_template('reports/dashboard.html',
start_date=start_date,
end_date=end_date,
revenue_by_status=revenue_by_status,
top_clients=top_clients,
monthly_revenue=monthly_revenue)
@app.route('/api/clients/search')
@login_required
def api_client_search():
"""API endpoint for client autocomplete"""
query = request.args.get('q', '')
clients = Client.query.filter(
Client.user_id == session['user_id'],
Client.name.contains(query)
).limit(10).all()
return jsonify([{
'id': client.id,
'name': client.name,
'email': client.email,
'company': client.company
} for client in clients])
@app.route('/settings', methods=['GET', 'POST'])
@login_required
def settings():
"""User settings and preferences"""
user = User.query.get(session['user_id'])
if request.method == 'POST':
action = request.form.get('action')
if action == 'update_profile':
user.company_name = request.form.get('company_name', '').strip()
user.company_address = request.form.get('company_address', '').strip()
user.email = request.form.get('email', '').strip().lower()
db.session.commit()
flash('Profile updated successfully!', 'success')
elif action == 'change_password':
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
if not user.check_password(current_password):
flash('Current password is incorrect.', 'danger')
elif new_password != confirm_password:
flash('New passwords do not match.', 'danger')
elif len(new_password) < 6:
flash('New password must be at least 6 characters long.', 'danger')
else:
user.set_password(new_password)
db.session.commit()
flash('Password changed successfully!', 'success')
return render_template('settings.html', user=user)
# Error Handlers
@app.errorhandler(404)
def not_found(error):
"""Custom 404 error page"""
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
"""Custom 500 error page"""
db.session.rollback()
logger.error(f"Internal error: {str(error)}")
return render_template('errors/500.html'), 500
@app.errorhandler(403)
def forbidden(error):
"""Custom 403 error page"""
return render_template('errors/403.html'), 403
# Database initialization and sample data
def create_sample_data():
"""Create sample data for testing"""
try:
# Create sample user
if not User.query.filter_by(username='demo').first():
demo_user = User(
username='demo',
email='demo@invoicing.com',
company_name='Demo Company Ltd.',
company_address='123 Business Street\nDemo City, DC 12345\nUnited States'
)
demo_user.set_password('demo123')
db.session.add(demo_user)
db.session.flush()
# Create sample clients
clients_data = [
{
'name': 'John Smith',
'email': 'john@example.com',
'company': 'Smith & Associates',
'phone': '+1-555-0123',
'address': '456 Client Avenue\nClient City, CC 67890',
'vat_number': 'VAT123456789'
},
{
'name': 'Sarah Johnson',
'email': 'sarah@techcorp.com',
'company': 'TechCorp Solutions',
'phone': '+1-555-0456',
'address': '789 Technology Blvd\nTech City, TC 13579',
'vat_number': 'VAT987654321'
},
{
'name': 'Mike Wilson',
'email': 'mike@freelance.com',
'company': 'Wilson Freelance',
'phone': '+1-555-0789',
'address': '321 Freelancer Lane\nWork City, WC 24680'
}
]
sample_clients = []
for client_data in clients_data:
client = Client(user_id=demo_user.id, **client_data)
db.session.add(client)
sample_clients.append(client)
db.session.flush()
# Create sample invoices
for i, client in enumerate(sample_clients):
# Create a paid invoice
paid_invoice = Invoice(
user_id=demo_user.id,
client_id=client.id,
invoice_number=f"2025-{str(i+1).zfill(4)}",
issue_date=(datetime.now() - timedelta(days=30-i*5)).date(),
due_date=(datetime.now() - timedelta(days=i*5)).date(),
subtotal=1000.00,
tax_rate=10.0,
tax_amount=100.00,
discount_rate=0.0,
discount_amount=0.00,
total_amount=1100.00,
status='paid',
payment_date=(datetime.now() - timedelta(days=i*3)).date(),
payment_method='Bank Transfer',
notes=f'Thank you for your business, {client.name}!',
payment_terms='Net 30'
)
db.session.add(paid_invoice)
db.session.flush()
# Add items to paid invoice
items = [
{'description': 'Web Development Services', 'quantity': 40, 'unit_price': 75.00},
{'description': 'Project Management', 'quantity': 10, 'unit_price': 50.00}
]
for item_data in items:
item = InvoiceItem(
invoice_id=paid_invoice.id,
description=item_data['description'],
quantity=item_data['quantity'],
unit_price=item_data['unit_price'],
line_total=item_data['quantity'] * item_data['unit_price']
)
db.session.add(item)
# Create a pending invoice
if i < 2: # Only for first two clients
pending_invoice = Invoice(
user_id=demo_user.id,
client_id=client.id,
invoice_number=f"2025-{str(i+10).zfill(4)}",
issue_date=datetime.now().date(),
due_date=(datetime.now() + timedelta(days=30)).date(),
subtotal=750.00,
tax_rate=10.0,
tax_amount=75.00,
discount_rate=5.0,
discount_amount=37.50,
total_amount=787.50,
status='sent',
sent_at=datetime.now(),
notes='Please review and approve the attached invoice.',
payment_terms='Net 30'
)
db.session.add(pending_invoice)
db.session.flush()
# Add items to pending invoice
pending_items = [
{'description': 'UI/UX Design Services', 'quantity': 15, 'unit_price': 80.00},
{'description': 'Code Review & Testing', 'quantity': 5, 'unit_price': 60.00}
]
for item_data in pending_items:
item = InvoiceItem(
invoice_id=pending_invoice.id,
description=item_data['description'],
quantity=item_data['quantity'],
unit_price=item_data['unit_price'],
line_total=item_data['quantity'] * item_data['unit_price']
)
db.session.add(item)
db.session.commit()
logger.info("Sample data created successfully")
except Exception as e:
db.session.rollback()
logger.error(f"Error creating sample data: {str(e)}")
# Application initialization
def init_app():
"""Initialize the application with database and sample data"""
with app.app_context():
db.create_all()
create_sample_data()
# Run application
if __name__ == '__main__':
init_app()
app.run(debug=True, host='0.0.0.0', port=5000)
Comments
Post a Comment