mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-07 03:59:48 -06:00
Merge pull request #144 from DRYTRIX/Feat-Expense-Tracking
feat: Add comprehensive expense tracking system
This commit is contained in:
@@ -764,6 +764,7 @@ def create_app(config=None):
|
||||
from app.routes.saved_filters import saved_filters_bp
|
||||
from app.routes.settings import settings_bp
|
||||
from app.routes.weekly_goals import weekly_goals_bp
|
||||
from app.routes.expenses import expenses_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
@@ -785,6 +786,7 @@ def create_app(config=None):
|
||||
app.register_blueprint(saved_filters_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(weekly_goals_bp)
|
||||
app.register_blueprint(expenses_bp)
|
||||
|
||||
# Exempt API blueprint from CSRF protection (JSON API uses authentication, not CSRF tokens)
|
||||
# Only if CSRF is enabled
|
||||
|
||||
@@ -24,6 +24,7 @@ from .activity import Activity
|
||||
from .user_favorite_project import UserFavoriteProject
|
||||
from .client_note import ClientNote
|
||||
from .weekly_time_goal import WeeklyTimeGoal
|
||||
from .expense import Expense
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -56,4 +57,5 @@ __all__ = [
|
||||
"UserFavoriteProject",
|
||||
"ClientNote",
|
||||
"WeeklyTimeGoal",
|
||||
"Expense",
|
||||
]
|
||||
|
||||
380
app/models/expense.py
Normal file
380
app/models/expense.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from app import db
|
||||
from sqlalchemy import Index
|
||||
|
||||
|
||||
class Expense(db.Model):
|
||||
"""Expense tracking model for business expenses"""
|
||||
|
||||
__tablename__ = 'expenses'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
|
||||
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True)
|
||||
|
||||
# Expense details
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
category = db.Column(db.String(50), nullable=False) # 'travel', 'meals', 'accommodation', 'supplies', 'software', 'equipment', 'services', 'other'
|
||||
amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
|
||||
|
||||
# Tax information
|
||||
tax_amount = db.Column(db.Numeric(10, 2), nullable=True, default=0)
|
||||
tax_rate = db.Column(db.Numeric(5, 2), nullable=True, default=0) # Percentage
|
||||
|
||||
# Payment information
|
||||
payment_method = db.Column(db.String(50), nullable=True) # 'cash', 'credit_card', 'bank_transfer', 'company_card', etc.
|
||||
payment_date = db.Column(db.Date, nullable=True)
|
||||
|
||||
# Status and approval
|
||||
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed'
|
||||
approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
|
||||
approved_at = db.Column(db.DateTime, nullable=True)
|
||||
rejection_reason = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Billing and invoicing
|
||||
billable = db.Column(db.Boolean, default=False, nullable=False)
|
||||
reimbursable = db.Column(db.Boolean, default=True, nullable=False)
|
||||
invoiced = db.Column(db.Boolean, default=False, nullable=False)
|
||||
invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=True, index=True)
|
||||
reimbursed = db.Column(db.Boolean, default=False, nullable=False)
|
||||
reimbursed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Date and metadata
|
||||
expense_date = db.Column(db.Date, nullable=False, index=True)
|
||||
receipt_path = db.Column(db.String(500), nullable=True)
|
||||
receipt_number = db.Column(db.String(100), nullable=True)
|
||||
vendor = db.Column(db.String(200), nullable=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Tags for categorization
|
||||
tags = db.Column(db.String(500), nullable=True) # Comma-separated tags
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('expenses', lazy='dynamic'))
|
||||
approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_expenses', lazy='dynamic'))
|
||||
project = db.relationship('Project', backref=db.backref('expenses', lazy='dynamic'))
|
||||
client = db.relationship('Client', backref=db.backref('expenses', lazy='dynamic'))
|
||||
invoice = db.relationship('Invoice', backref=db.backref('expenses', lazy='dynamic'))
|
||||
|
||||
# Add composite indexes for common query patterns
|
||||
__table_args__ = (
|
||||
Index('ix_expenses_user_date', 'user_id', 'expense_date'),
|
||||
Index('ix_expenses_status_date', 'status', 'expense_date'),
|
||||
Index('ix_expenses_project_date', 'project_id', 'expense_date'),
|
||||
)
|
||||
|
||||
def __init__(self, user_id, title, category, amount, expense_date, **kwargs):
|
||||
self.user_id = user_id
|
||||
self.title = title.strip() if title else None
|
||||
self.category = category
|
||||
self.amount = Decimal(str(amount))
|
||||
self.expense_date = expense_date
|
||||
|
||||
# Optional fields
|
||||
self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None
|
||||
self.project_id = kwargs.get('project_id')
|
||||
self.client_id = kwargs.get('client_id')
|
||||
self.currency_code = kwargs.get('currency_code', 'EUR')
|
||||
self.tax_amount = Decimal(str(kwargs.get('tax_amount', 0)))
|
||||
self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0)))
|
||||
self.payment_method = kwargs.get('payment_method')
|
||||
self.payment_date = kwargs.get('payment_date')
|
||||
self.billable = kwargs.get('billable', False)
|
||||
self.reimbursable = kwargs.get('reimbursable', True)
|
||||
self.receipt_path = kwargs.get('receipt_path')
|
||||
self.receipt_number = kwargs.get('receipt_number')
|
||||
self.vendor = kwargs.get('vendor')
|
||||
self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None
|
||||
self.tags = kwargs.get('tags')
|
||||
self.status = kwargs.get('status', 'pending')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Expense {self.title} ({self.amount} {self.currency_code})>'
|
||||
|
||||
@property
|
||||
def is_approved(self):
|
||||
"""Check if expense is approved"""
|
||||
return self.status == 'approved'
|
||||
|
||||
@property
|
||||
def is_rejected(self):
|
||||
"""Check if expense is rejected"""
|
||||
return self.status == 'rejected'
|
||||
|
||||
@property
|
||||
def is_reimbursed(self):
|
||||
"""Check if expense has been reimbursed"""
|
||||
return self.reimbursed and self.reimbursed_at is not None
|
||||
|
||||
@property
|
||||
def is_invoiced(self):
|
||||
"""Check if this expense has been invoiced"""
|
||||
return self.invoiced and self.invoice_id is not None
|
||||
|
||||
@property
|
||||
def total_amount(self):
|
||||
"""Calculate total amount including tax"""
|
||||
return self.amount + (self.tax_amount or 0)
|
||||
|
||||
@property
|
||||
def tag_list(self):
|
||||
"""Get list of tags"""
|
||||
if not self.tags:
|
||||
return []
|
||||
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
|
||||
|
||||
def approve(self, approved_by_user_id, notes=None):
|
||||
"""Approve the expense"""
|
||||
self.status = 'approved'
|
||||
self.approved_by = approved_by_user_id
|
||||
self.approved_at = datetime.utcnow()
|
||||
if notes:
|
||||
self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}'
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def reject(self, rejected_by_user_id, reason):
|
||||
"""Reject the expense"""
|
||||
self.status = 'rejected'
|
||||
self.approved_by = rejected_by_user_id
|
||||
self.approved_at = datetime.utcnow()
|
||||
self.rejection_reason = reason
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def mark_as_reimbursed(self):
|
||||
"""Mark this expense as reimbursed"""
|
||||
self.reimbursed = True
|
||||
self.reimbursed_at = datetime.utcnow()
|
||||
self.status = 'reimbursed'
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def mark_as_invoiced(self, invoice_id):
|
||||
"""Mark this expense as invoiced"""
|
||||
self.invoiced = True
|
||||
self.invoice_id = invoice_id
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def unmark_as_invoiced(self):
|
||||
"""Unmark this expense as invoiced (e.g., if invoice is deleted)"""
|
||||
self.invoiced = False
|
||||
self.invoice_id = None
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert expense to dictionary for API responses"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
'project_id': self.project_id,
|
||||
'client_id': self.client_id,
|
||||
'title': self.title,
|
||||
'description': self.description,
|
||||
'category': self.category,
|
||||
'amount': float(self.amount),
|
||||
'currency_code': self.currency_code,
|
||||
'tax_amount': float(self.tax_amount) if self.tax_amount else 0,
|
||||
'tax_rate': float(self.tax_rate) if self.tax_rate else 0,
|
||||
'total_amount': float(self.total_amount),
|
||||
'payment_method': self.payment_method,
|
||||
'payment_date': self.payment_date.isoformat() if self.payment_date else None,
|
||||
'status': self.status,
|
||||
'approved_by': self.approved_by,
|
||||
'approved_at': self.approved_at.isoformat() if self.approved_at else None,
|
||||
'rejection_reason': self.rejection_reason,
|
||||
'billable': self.billable,
|
||||
'reimbursable': self.reimbursable,
|
||||
'invoiced': self.invoiced,
|
||||
'invoice_id': self.invoice_id,
|
||||
'reimbursed': self.reimbursed,
|
||||
'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None,
|
||||
'expense_date': self.expense_date.isoformat() if self.expense_date else None,
|
||||
'receipt_path': self.receipt_path,
|
||||
'receipt_number': self.receipt_number,
|
||||
'vendor': self.vendor,
|
||||
'notes': self.notes,
|
||||
'tags': self.tag_list,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'user': self.user.username if self.user else None,
|
||||
'project': self.project.name if self.project else None,
|
||||
'client': self.client.name if self.client else None,
|
||||
'approver': self.approver.username if self.approver else None
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_expenses(cls, user_id=None, project_id=None, client_id=None,
|
||||
start_date=None, end_date=None, status=None,
|
||||
category=None, billable_only=False, reimbursable_only=False):
|
||||
"""Get expenses with optional filters"""
|
||||
query = cls.query
|
||||
|
||||
if user_id:
|
||||
query = query.filter(cls.user_id == user_id)
|
||||
|
||||
if project_id:
|
||||
query = query.filter(cls.project_id == project_id)
|
||||
|
||||
if client_id:
|
||||
query = query.filter(cls.client_id == client_id)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(cls.expense_date >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(cls.expense_date <= end_date)
|
||||
|
||||
if status:
|
||||
query = query.filter(cls.status == status)
|
||||
|
||||
if category:
|
||||
query = query.filter(cls.category == category)
|
||||
|
||||
if billable_only:
|
||||
query = query.filter(cls.billable == True)
|
||||
|
||||
if reimbursable_only:
|
||||
query = query.filter(cls.reimbursable == True)
|
||||
|
||||
return query.order_by(cls.expense_date.desc()).all()
|
||||
|
||||
@classmethod
|
||||
def get_total_expenses(cls, user_id=None, project_id=None, client_id=None,
|
||||
start_date=None, end_date=None, status=None,
|
||||
category=None, include_tax=True):
|
||||
"""Calculate total expenses with optional filters"""
|
||||
query = db.session.query(
|
||||
db.func.sum(cls.amount if not include_tax else cls.amount + db.func.coalesce(cls.tax_amount, 0))
|
||||
)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(cls.user_id == user_id)
|
||||
|
||||
if project_id:
|
||||
query = query.filter(cls.project_id == project_id)
|
||||
|
||||
if client_id:
|
||||
query = query.filter(cls.client_id == client_id)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(cls.expense_date >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(cls.expense_date <= end_date)
|
||||
|
||||
if status:
|
||||
query = query.filter(cls.status == status)
|
||||
|
||||
if category:
|
||||
query = query.filter(cls.category == category)
|
||||
|
||||
total = query.scalar() or Decimal('0')
|
||||
return float(total)
|
||||
|
||||
@classmethod
|
||||
def get_expenses_by_category(cls, user_id=None, start_date=None, end_date=None, status=None):
|
||||
"""Get expenses grouped by category"""
|
||||
query = db.session.query(
|
||||
cls.category,
|
||||
db.func.sum(cls.amount + db.func.coalesce(cls.tax_amount, 0)).label('total_amount'),
|
||||
db.func.count(cls.id).label('count')
|
||||
)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(cls.user_id == user_id)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(cls.expense_date >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(cls.expense_date <= end_date)
|
||||
|
||||
if status:
|
||||
query = query.filter(cls.status == status)
|
||||
|
||||
results = query.group_by(cls.category).all()
|
||||
|
||||
return [
|
||||
{
|
||||
'category': category,
|
||||
'total_amount': float(total_amount),
|
||||
'count': count
|
||||
}
|
||||
for category, total_amount, count in results
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_pending_approvals(cls, user_id=None):
|
||||
"""Get expenses pending approval"""
|
||||
query = cls.query.filter_by(status='pending')
|
||||
|
||||
if user_id:
|
||||
query = query.filter(cls.user_id == user_id)
|
||||
|
||||
return query.order_by(cls.expense_date.desc()).all()
|
||||
|
||||
@classmethod
|
||||
def get_pending_reimbursements(cls, user_id=None):
|
||||
"""Get approved expenses pending reimbursement"""
|
||||
query = cls.query.filter(
|
||||
cls.status == 'approved',
|
||||
cls.reimbursable == True,
|
||||
cls.reimbursed == False
|
||||
)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(cls.user_id == user_id)
|
||||
|
||||
return query.order_by(cls.expense_date.desc()).all()
|
||||
|
||||
@classmethod
|
||||
def get_uninvoiced_expenses(cls, project_id=None, client_id=None):
|
||||
"""Get billable expenses that haven't been invoiced yet"""
|
||||
query = cls.query.filter(
|
||||
cls.status == 'approved',
|
||||
cls.billable == True,
|
||||
cls.invoiced == False
|
||||
)
|
||||
|
||||
if project_id:
|
||||
query = query.filter(cls.project_id == project_id)
|
||||
|
||||
if client_id:
|
||||
query = query.filter(cls.client_id == client_id)
|
||||
|
||||
return query.order_by(cls.expense_date.desc()).all()
|
||||
|
||||
@classmethod
|
||||
def get_expense_categories(cls):
|
||||
"""Get list of available expense categories"""
|
||||
return [
|
||||
'travel',
|
||||
'meals',
|
||||
'accommodation',
|
||||
'supplies',
|
||||
'software',
|
||||
'equipment',
|
||||
'services',
|
||||
'marketing',
|
||||
'training',
|
||||
'other'
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_payment_methods(cls):
|
||||
"""Get list of available payment methods"""
|
||||
return [
|
||||
'cash',
|
||||
'credit_card',
|
||||
'debit_card',
|
||||
'bank_transfer',
|
||||
'company_card',
|
||||
'paypal',
|
||||
'other'
|
||||
]
|
||||
|
||||
885
app/routes/expenses.py
Normal file
885
app/routes/expenses.py
Normal file
@@ -0,0 +1,885 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, current_app
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, log_event, track_event
|
||||
from app.models import Expense, Project, Client, User
|
||||
from datetime import datetime, date, timedelta
|
||||
from decimal import Decimal
|
||||
from app.utils.db import safe_commit
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
expenses_bp = Blueprint('expenses', __name__)
|
||||
|
||||
# File upload configuration
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
|
||||
UPLOAD_FOLDER = 'uploads/receipts'
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
"""Check if file extension is allowed"""
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses')
|
||||
@login_required
|
||||
def list_expenses():
|
||||
"""List all expenses with filters"""
|
||||
# Track page view
|
||||
from app import track_page_view
|
||||
track_page_view("expenses_list")
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 25, type=int)
|
||||
|
||||
# Filter parameters
|
||||
status = request.args.get('status', '').strip()
|
||||
category = request.args.get('category', '').strip()
|
||||
project_id = request.args.get('project_id', type=int)
|
||||
client_id = request.args.get('client_id', type=int)
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
start_date = request.args.get('start_date', '').strip()
|
||||
end_date = request.args.get('end_date', '').strip()
|
||||
search = request.args.get('search', '').strip()
|
||||
billable = request.args.get('billable', '').strip()
|
||||
reimbursable = request.args.get('reimbursable', '').strip()
|
||||
|
||||
# Build query
|
||||
query = Expense.query
|
||||
|
||||
# Non-admin users can only see their own expenses or expenses they approved
|
||||
if not current_user.is_admin:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.approved_by == current_user.id
|
||||
)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
query = query.filter(Expense.status == status)
|
||||
|
||||
if category:
|
||||
query = query.filter(Expense.category == category)
|
||||
|
||||
if project_id:
|
||||
query = query.filter(Expense.project_id == project_id)
|
||||
|
||||
if client_id:
|
||||
query = query.filter(Expense.client_id == client_id)
|
||||
|
||||
if user_id and current_user.is_admin:
|
||||
query = query.filter(Expense.user_id == user_id)
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Expense.expense_date >= start)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Expense.expense_date <= end)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Expense.title.ilike(like),
|
||||
Expense.description.ilike(like),
|
||||
Expense.vendor.ilike(like),
|
||||
Expense.notes.ilike(like)
|
||||
)
|
||||
)
|
||||
|
||||
if billable == 'true':
|
||||
query = query.filter(Expense.billable == True)
|
||||
elif billable == 'false':
|
||||
query = query.filter(Expense.billable == False)
|
||||
|
||||
if reimbursable == 'true':
|
||||
query = query.filter(Expense.reimbursable == True)
|
||||
elif reimbursable == 'false':
|
||||
query = query.filter(Expense.reimbursable == False)
|
||||
|
||||
# Paginate
|
||||
expenses_pagination = query.order_by(Expense.expense_date.desc()).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
# Get filter options
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
clients = Client.get_active_clients()
|
||||
categories = Expense.get_expense_categories()
|
||||
|
||||
# Get users for admin filter
|
||||
users = []
|
||||
if current_user.is_admin:
|
||||
users = User.query.filter_by(is_active=True).order_by(User.username).all()
|
||||
|
||||
# Calculate totals for current filters (without pagination)
|
||||
total_amount = 0
|
||||
total_count = query.count()
|
||||
|
||||
if total_count > 0:
|
||||
total_query = db.session.query(
|
||||
db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0))
|
||||
)
|
||||
|
||||
# Apply same filters
|
||||
if status:
|
||||
total_query = total_query.filter(Expense.status == status)
|
||||
if category:
|
||||
total_query = total_query.filter(Expense.category == category)
|
||||
if project_id:
|
||||
total_query = total_query.filter(Expense.project_id == project_id)
|
||||
if client_id:
|
||||
total_query = total_query.filter(Expense.client_id == client_id)
|
||||
if user_id and current_user.is_admin:
|
||||
total_query = total_query.filter(Expense.user_id == user_id)
|
||||
if start_date:
|
||||
try:
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
total_query = total_query.filter(Expense.expense_date >= start)
|
||||
except ValueError:
|
||||
pass
|
||||
if end_date:
|
||||
try:
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
total_query = total_query.filter(Expense.expense_date <= end)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Non-admin users restriction
|
||||
if not current_user.is_admin:
|
||||
total_query = total_query.filter(
|
||||
db.or_(
|
||||
Expense.user_id == current_user.id,
|
||||
Expense.approved_by == current_user.id
|
||||
)
|
||||
)
|
||||
|
||||
total_amount = total_query.scalar() or 0
|
||||
|
||||
return render_template(
|
||||
'expenses/list.html',
|
||||
expenses=expenses_pagination.items,
|
||||
pagination=expenses_pagination,
|
||||
projects=projects,
|
||||
clients=clients,
|
||||
categories=categories,
|
||||
users=users,
|
||||
total_amount=float(total_amount),
|
||||
total_count=total_count,
|
||||
# Pass back filter values
|
||||
status=status,
|
||||
category=category,
|
||||
project_id=project_id,
|
||||
client_id=client_id,
|
||||
user_id=user_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
search=search,
|
||||
billable=billable,
|
||||
reimbursable=reimbursable
|
||||
)
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_expense():
|
||||
"""Create a new expense"""
|
||||
if request.method == 'GET':
|
||||
# Get data for form
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
clients = Client.get_active_clients()
|
||||
categories = Expense.get_expense_categories()
|
||||
payment_methods = Expense.get_payment_methods()
|
||||
|
||||
return render_template(
|
||||
'expenses/form.html',
|
||||
expense=None,
|
||||
projects=projects,
|
||||
clients=clients,
|
||||
categories=categories,
|
||||
payment_methods=payment_methods
|
||||
)
|
||||
|
||||
try:
|
||||
# Get form data
|
||||
title = request.form.get('title', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
category = request.form.get('category', '').strip()
|
||||
amount = request.form.get('amount', '0').strip()
|
||||
currency_code = request.form.get('currency_code', 'EUR').strip()
|
||||
tax_amount = request.form.get('tax_amount', '0').strip()
|
||||
expense_date = request.form.get('expense_date', '').strip()
|
||||
|
||||
# Validate required fields
|
||||
if not title:
|
||||
flash(_('Title is required'), 'error')
|
||||
return redirect(url_for('expenses.create_expense'))
|
||||
|
||||
if not category:
|
||||
flash(_('Category is required'), 'error')
|
||||
return redirect(url_for('expenses.create_expense'))
|
||||
|
||||
if not amount:
|
||||
flash(_('Amount is required'), 'error')
|
||||
return redirect(url_for('expenses.create_expense'))
|
||||
|
||||
if not expense_date:
|
||||
flash(_('Expense date is required'), 'error')
|
||||
return redirect(url_for('expenses.create_expense'))
|
||||
|
||||
# Parse date
|
||||
try:
|
||||
expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
flash(_('Invalid date format'), 'error')
|
||||
return redirect(url_for('expenses.create_expense'))
|
||||
|
||||
# Parse amounts
|
||||
try:
|
||||
amount_decimal = Decimal(amount)
|
||||
tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0')
|
||||
except (ValueError, Decimal.InvalidOperation):
|
||||
flash(_('Invalid amount format'), 'error')
|
||||
return redirect(url_for('expenses.create_expense'))
|
||||
|
||||
# Optional fields
|
||||
project_id = request.form.get('project_id', type=int)
|
||||
client_id = request.form.get('client_id', type=int)
|
||||
payment_method = request.form.get('payment_method', '').strip()
|
||||
payment_date = request.form.get('payment_date', '').strip()
|
||||
vendor = request.form.get('vendor', '').strip()
|
||||
receipt_number = request.form.get('receipt_number', '').strip()
|
||||
notes = request.form.get('notes', '').strip()
|
||||
tags = request.form.get('tags', '').strip()
|
||||
billable = request.form.get('billable') == 'on'
|
||||
reimbursable = request.form.get('reimbursable') == 'on'
|
||||
|
||||
# Parse payment date if provided
|
||||
payment_date_obj = None
|
||||
if payment_date:
|
||||
try:
|
||||
payment_date_obj = datetime.strptime(payment_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle file upload
|
||||
receipt_path = None
|
||||
if 'receipt_file' in request.files:
|
||||
file = request.files['receipt_file']
|
||||
if file and file.filename and allowed_file(file.filename):
|
||||
filename = secure_filename(file.filename)
|
||||
# Add timestamp to filename to avoid collisions
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"{timestamp}_{filename}"
|
||||
|
||||
# Ensure upload directory exists
|
||||
upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER)
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
file.save(file_path)
|
||||
receipt_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
|
||||
# Create expense
|
||||
expense = Expense(
|
||||
user_id=current_user.id,
|
||||
title=title,
|
||||
category=category,
|
||||
amount=amount_decimal,
|
||||
expense_date=expense_date_obj,
|
||||
description=description,
|
||||
currency_code=currency_code,
|
||||
tax_amount=tax_amount_decimal,
|
||||
project_id=project_id,
|
||||
client_id=client_id,
|
||||
payment_method=payment_method,
|
||||
payment_date=payment_date_obj,
|
||||
vendor=vendor,
|
||||
receipt_number=receipt_number,
|
||||
receipt_path=receipt_path,
|
||||
notes=notes,
|
||||
tags=tags,
|
||||
billable=billable,
|
||||
reimbursable=reimbursable
|
||||
)
|
||||
|
||||
db.session.add(expense)
|
||||
|
||||
if safe_commit(db):
|
||||
flash(_('Expense created successfully'), 'success')
|
||||
log_event('expense_created', user_id=current_user.id, expense_id=expense.id)
|
||||
track_event(current_user.id, 'expense.created', {
|
||||
'expense_id': expense.id,
|
||||
'category': category,
|
||||
'amount': float(amount_decimal),
|
||||
'billable': billable,
|
||||
'reimbursable': reimbursable
|
||||
})
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense.id))
|
||||
else:
|
||||
flash(_('Error creating expense'), 'error')
|
||||
return redirect(url_for('expenses.create_expense'))
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error creating expense: {e}")
|
||||
flash(_('Error creating expense'), 'error')
|
||||
return redirect(url_for('expenses.create_expense'))
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses/<int:expense_id>')
|
||||
@login_required
|
||||
def view_expense(expense_id):
|
||||
"""View expense details"""
|
||||
expense = Expense.query.get_or_404(expense_id)
|
||||
|
||||
# Check permission
|
||||
if not current_user.is_admin and expense.user_id != current_user.id and expense.approved_by != current_user.id:
|
||||
flash(_('You do not have permission to view this expense'), 'error')
|
||||
return redirect(url_for('expenses.list_expenses'))
|
||||
|
||||
# Track page view
|
||||
from app import track_page_view
|
||||
track_page_view("expense_detail", properties={'expense_id': expense_id})
|
||||
|
||||
return render_template('expenses/view.html', expense=expense)
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses/<int:expense_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_expense(expense_id):
|
||||
"""Edit an existing expense"""
|
||||
expense = Expense.query.get_or_404(expense_id)
|
||||
|
||||
# Check permission - only owner can edit (unless admin)
|
||||
if not current_user.is_admin and expense.user_id != current_user.id:
|
||||
flash(_('You do not have permission to edit this expense'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
# Cannot edit approved or reimbursed expenses without admin privileges
|
||||
if not current_user.is_admin and expense.status in ['approved', 'reimbursed']:
|
||||
flash(_('Cannot edit approved or reimbursed expenses'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
if request.method == 'GET':
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
clients = Client.get_active_clients()
|
||||
categories = Expense.get_expense_categories()
|
||||
payment_methods = Expense.get_payment_methods()
|
||||
|
||||
return render_template(
|
||||
'expenses/form.html',
|
||||
expense=expense,
|
||||
projects=projects,
|
||||
clients=clients,
|
||||
categories=categories,
|
||||
payment_methods=payment_methods
|
||||
)
|
||||
|
||||
try:
|
||||
# Get form data
|
||||
title = request.form.get('title', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
category = request.form.get('category', '').strip()
|
||||
amount = request.form.get('amount', '0').strip()
|
||||
currency_code = request.form.get('currency_code', 'EUR').strip()
|
||||
tax_amount = request.form.get('tax_amount', '0').strip()
|
||||
expense_date = request.form.get('expense_date', '').strip()
|
||||
|
||||
# Validate required fields
|
||||
if not title or not category or not amount or not expense_date:
|
||||
flash(_('Please fill in all required fields'), 'error')
|
||||
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
|
||||
|
||||
# Parse date
|
||||
try:
|
||||
expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
flash(_('Invalid date format'), 'error')
|
||||
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
|
||||
|
||||
# Parse amounts
|
||||
try:
|
||||
amount_decimal = Decimal(amount)
|
||||
tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0')
|
||||
except (ValueError, Decimal.InvalidOperation):
|
||||
flash(_('Invalid amount format'), 'error')
|
||||
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
|
||||
|
||||
# Update expense fields
|
||||
expense.title = title
|
||||
expense.description = description
|
||||
expense.category = category
|
||||
expense.amount = amount_decimal
|
||||
expense.currency_code = currency_code
|
||||
expense.tax_amount = tax_amount_decimal
|
||||
expense.expense_date = expense_date_obj
|
||||
|
||||
# Optional fields
|
||||
expense.project_id = request.form.get('project_id', type=int)
|
||||
expense.client_id = request.form.get('client_id', type=int)
|
||||
expense.payment_method = request.form.get('payment_method', '').strip()
|
||||
expense.vendor = request.form.get('vendor', '').strip()
|
||||
expense.receipt_number = request.form.get('receipt_number', '').strip()
|
||||
expense.notes = request.form.get('notes', '').strip()
|
||||
expense.tags = request.form.get('tags', '').strip()
|
||||
expense.billable = request.form.get('billable') == 'on'
|
||||
expense.reimbursable = request.form.get('reimbursable') == 'on'
|
||||
|
||||
# Parse payment date if provided
|
||||
payment_date = request.form.get('payment_date', '').strip()
|
||||
if payment_date:
|
||||
try:
|
||||
expense.payment_date = datetime.strptime(payment_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
expense.payment_date = None
|
||||
else:
|
||||
expense.payment_date = None
|
||||
|
||||
# Handle file upload
|
||||
if 'receipt_file' in request.files:
|
||||
file = request.files['receipt_file']
|
||||
if file and file.filename and allowed_file(file.filename):
|
||||
filename = secure_filename(file.filename)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"{timestamp}_{filename}"
|
||||
|
||||
upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER)
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Delete old receipt if exists
|
||||
if expense.receipt_path:
|
||||
old_file_path = os.path.join(current_app.root_path, '..', expense.receipt_path)
|
||||
if os.path.exists(old_file_path):
|
||||
try:
|
||||
os.remove(old_file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
expense.receipt_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
|
||||
expense.updated_at = datetime.utcnow()
|
||||
|
||||
if safe_commit(db):
|
||||
flash(_('Expense updated successfully'), 'success')
|
||||
log_event('expense_updated', user_id=current_user.id, expense_id=expense.id)
|
||||
track_event(current_user.id, 'expense.updated', {'expense_id': expense.id})
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense.id))
|
||||
else:
|
||||
flash(_('Error updating expense'), 'error')
|
||||
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error updating expense: {e}")
|
||||
flash(_('Error updating expense'), 'error')
|
||||
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses/<int:expense_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_expense(expense_id):
|
||||
"""Delete an expense"""
|
||||
expense = Expense.query.get_or_404(expense_id)
|
||||
|
||||
# Check permission
|
||||
if not current_user.is_admin and expense.user_id != current_user.id:
|
||||
flash(_('You do not have permission to delete this expense'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
# Cannot delete approved or invoiced expenses without admin privileges
|
||||
if not current_user.is_admin and (expense.status == 'approved' or expense.invoiced):
|
||||
flash(_('Cannot delete approved or invoiced expenses'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
try:
|
||||
# Delete receipt file if exists
|
||||
if expense.receipt_path:
|
||||
file_path = os.path.join(current_app.root_path, '..', expense.receipt_path)
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.session.delete(expense)
|
||||
|
||||
if safe_commit(db):
|
||||
flash(_('Expense deleted successfully'), 'success')
|
||||
log_event('expense_deleted', user_id=current_user.id, expense_id=expense_id)
|
||||
track_event(current_user.id, 'expense.deleted', {'expense_id': expense_id})
|
||||
else:
|
||||
flash(_('Error deleting expense'), 'error')
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error deleting expense: {e}")
|
||||
flash(_('Error deleting expense'), 'error')
|
||||
|
||||
return redirect(url_for('expenses.list_expenses'))
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses/<int:expense_id>/approve', methods=['POST'])
|
||||
@login_required
|
||||
def approve_expense(expense_id):
|
||||
"""Approve an expense"""
|
||||
if not current_user.is_admin:
|
||||
flash(_('Only administrators can approve expenses'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
expense = Expense.query.get_or_404(expense_id)
|
||||
|
||||
if expense.status != 'pending':
|
||||
flash(_('Only pending expenses can be approved'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
try:
|
||||
notes = request.form.get('approval_notes', '').strip()
|
||||
expense.approve(current_user.id, notes)
|
||||
|
||||
if safe_commit(db):
|
||||
flash(_('Expense approved successfully'), 'success')
|
||||
log_event('expense_approved', user_id=current_user.id, expense_id=expense_id)
|
||||
track_event(current_user.id, 'expense.approved', {'expense_id': expense_id})
|
||||
else:
|
||||
flash(_('Error approving expense'), 'error')
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error approving expense: {e}")
|
||||
flash(_('Error approving expense'), 'error')
|
||||
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses/<int:expense_id>/reject', methods=['POST'])
|
||||
@login_required
|
||||
def reject_expense(expense_id):
|
||||
"""Reject an expense"""
|
||||
if not current_user.is_admin:
|
||||
flash(_('Only administrators can reject expenses'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
expense = Expense.query.get_or_404(expense_id)
|
||||
|
||||
if expense.status != 'pending':
|
||||
flash(_('Only pending expenses can be rejected'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
try:
|
||||
reason = request.form.get('rejection_reason', '').strip()
|
||||
if not reason:
|
||||
flash(_('Rejection reason is required'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
expense.reject(current_user.id, reason)
|
||||
|
||||
if safe_commit(db):
|
||||
flash(_('Expense rejected'), 'success')
|
||||
log_event('expense_rejected', user_id=current_user.id, expense_id=expense_id)
|
||||
track_event(current_user.id, 'expense.rejected', {'expense_id': expense_id})
|
||||
else:
|
||||
flash(_('Error rejecting expense'), 'error')
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error rejecting expense: {e}")
|
||||
flash(_('Error rejecting expense'), 'error')
|
||||
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses/<int:expense_id>/reimburse', methods=['POST'])
|
||||
@login_required
|
||||
def mark_reimbursed(expense_id):
|
||||
"""Mark an expense as reimbursed"""
|
||||
if not current_user.is_admin:
|
||||
flash(_('Only administrators can mark expenses as reimbursed'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
expense = Expense.query.get_or_404(expense_id)
|
||||
|
||||
if expense.status != 'approved':
|
||||
flash(_('Only approved expenses can be marked as reimbursed'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
if not expense.reimbursable:
|
||||
flash(_('This expense is not marked as reimbursable'), 'error')
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
try:
|
||||
expense.mark_as_reimbursed()
|
||||
|
||||
if safe_commit(db):
|
||||
flash(_('Expense marked as reimbursed'), 'success')
|
||||
log_event('expense_reimbursed', user_id=current_user.id, expense_id=expense_id)
|
||||
track_event(current_user.id, 'expense.reimbursed', {'expense_id': expense_id})
|
||||
else:
|
||||
flash(_('Error marking expense as reimbursed'), 'error')
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error marking expense as reimbursed: {e}")
|
||||
flash(_('Error marking expense as reimbursed'), 'error')
|
||||
|
||||
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses/export')
|
||||
@login_required
|
||||
def export_expenses():
|
||||
"""Export expenses to CSV"""
|
||||
# Get filter parameters (same as list_expenses)
|
||||
status = request.args.get('status', '').strip()
|
||||
category = request.args.get('category', '').strip()
|
||||
project_id = request.args.get('project_id', type=int)
|
||||
client_id = request.args.get('client_id', type=int)
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
start_date = request.args.get('start_date', '').strip()
|
||||
end_date = request.args.get('end_date', '').strip()
|
||||
|
||||
# Build query
|
||||
query = Expense.query
|
||||
|
||||
# Non-admin users can only export their own expenses
|
||||
if not current_user.is_admin:
|
||||
query = query.filter(Expense.user_id == current_user.id)
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
query = query.filter(Expense.status == status)
|
||||
if category:
|
||||
query = query.filter(Expense.category == category)
|
||||
if project_id:
|
||||
query = query.filter(Expense.project_id == project_id)
|
||||
if client_id:
|
||||
query = query.filter(Expense.client_id == client_id)
|
||||
if user_id and current_user.is_admin:
|
||||
query = query.filter(Expense.user_id == user_id)
|
||||
if start_date:
|
||||
try:
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Expense.expense_date >= start)
|
||||
except ValueError:
|
||||
pass
|
||||
if end_date:
|
||||
try:
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Expense.expense_date <= end)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
expenses = query.order_by(Expense.expense_date.desc()).all()
|
||||
|
||||
# Create CSV
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Write header
|
||||
writer.writerow([
|
||||
'Date', 'Title', 'Category', 'Amount', 'Tax', 'Total', 'Currency',
|
||||
'Status', 'Vendor', 'Payment Method', 'Project', 'Client', 'User',
|
||||
'Billable', 'Reimbursable', 'Invoiced', 'Receipt Number', 'Notes'
|
||||
])
|
||||
|
||||
# Write data
|
||||
for expense in expenses:
|
||||
writer.writerow([
|
||||
expense.expense_date.isoformat() if expense.expense_date else '',
|
||||
expense.title,
|
||||
expense.category,
|
||||
float(expense.amount),
|
||||
float(expense.tax_amount) if expense.tax_amount else 0,
|
||||
float(expense.total_amount),
|
||||
expense.currency_code,
|
||||
expense.status,
|
||||
expense.vendor or '',
|
||||
expense.payment_method or '',
|
||||
expense.project.name if expense.project else '',
|
||||
expense.client.name if expense.client else '',
|
||||
expense.user.username if expense.user else '',
|
||||
'Yes' if expense.billable else 'No',
|
||||
'Yes' if expense.reimbursable else 'No',
|
||||
'Yes' if expense.invoiced else 'No',
|
||||
expense.receipt_number or '',
|
||||
expense.notes or ''
|
||||
])
|
||||
|
||||
# Prepare response
|
||||
output.seek(0)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'expenses_{timestamp}.csv'
|
||||
|
||||
# Track export
|
||||
log_event('expenses_exported', user_id=current_user.id, count=len(expenses))
|
||||
track_event(current_user.id, 'expenses.exported', {'count': len(expenses)})
|
||||
|
||||
return send_file(
|
||||
io.BytesIO(output.getvalue().encode('utf-8')),
|
||||
mimetype='text/csv',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
|
||||
@expenses_bp.route('/expenses/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Expense dashboard with analytics"""
|
||||
# Track page view
|
||||
from app import track_page_view
|
||||
track_page_view("expenses_dashboard")
|
||||
|
||||
# Date range - default to current month
|
||||
today = date.today()
|
||||
start_date = date(today.year, today.month, 1)
|
||||
end_date = today
|
||||
|
||||
# Get date range from query params if provided
|
||||
start_date_str = request.args.get('start_date', '').strip()
|
||||
end_date_str = request.args.get('end_date', '').strip()
|
||||
|
||||
if start_date_str:
|
||||
try:
|
||||
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Build base query
|
||||
if current_user.is_admin:
|
||||
query = Expense.query
|
||||
else:
|
||||
query = Expense.query.filter_by(user_id=current_user.id)
|
||||
|
||||
# Apply date filter
|
||||
query = query.filter(
|
||||
Expense.expense_date >= start_date,
|
||||
Expense.expense_date <= end_date
|
||||
)
|
||||
|
||||
# Get statistics
|
||||
total_expenses = query.count()
|
||||
|
||||
# Total amount
|
||||
total_amount_query = db.session.query(
|
||||
db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0))
|
||||
).filter(Expense.expense_date >= start_date, Expense.expense_date <= end_date)
|
||||
|
||||
if not current_user.is_admin:
|
||||
total_amount_query = total_amount_query.filter(Expense.user_id == current_user.id)
|
||||
|
||||
total_amount = total_amount_query.scalar() or 0
|
||||
|
||||
# By status
|
||||
pending_count = query.filter_by(status='pending').count()
|
||||
approved_count = query.filter_by(status='approved').count()
|
||||
rejected_count = query.filter_by(status='rejected').count()
|
||||
reimbursed_count = query.filter_by(status='reimbursed').count()
|
||||
|
||||
# Pending reimbursement
|
||||
pending_reimbursement = query.filter(
|
||||
Expense.status == 'approved',
|
||||
Expense.reimbursable == True,
|
||||
Expense.reimbursed == False
|
||||
).count()
|
||||
|
||||
# By category
|
||||
category_stats = Expense.get_expenses_by_category(
|
||||
user_id=None if current_user.is_admin else current_user.id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
# Recent expenses
|
||||
recent_expenses = query.order_by(Expense.expense_date.desc()).limit(10).all()
|
||||
|
||||
return render_template(
|
||||
'expenses/dashboard.html',
|
||||
total_expenses=total_expenses,
|
||||
total_amount=float(total_amount),
|
||||
pending_count=pending_count,
|
||||
approved_count=approved_count,
|
||||
rejected_count=rejected_count,
|
||||
reimbursed_count=reimbursed_count,
|
||||
pending_reimbursement=pending_reimbursement,
|
||||
category_stats=category_stats,
|
||||
recent_expenses=recent_expenses,
|
||||
start_date=start_date.isoformat(),
|
||||
end_date=end_date.isoformat()
|
||||
)
|
||||
|
||||
|
||||
# API endpoints
|
||||
@expenses_bp.route('/api/expenses', methods=['GET'])
|
||||
@login_required
|
||||
def api_list_expenses():
|
||||
"""API endpoint to list expenses"""
|
||||
# Similar filters as list_expenses
|
||||
status = request.args.get('status', '').strip()
|
||||
category = request.args.get('category', '').strip()
|
||||
project_id = request.args.get('project_id', type=int)
|
||||
start_date = request.args.get('start_date', '').strip()
|
||||
end_date = request.args.get('end_date', '').strip()
|
||||
|
||||
# Build query
|
||||
query = Expense.query
|
||||
|
||||
if not current_user.is_admin:
|
||||
query = query.filter_by(user_id=current_user.id)
|
||||
|
||||
if status:
|
||||
query = query.filter(Expense.status == status)
|
||||
if category:
|
||||
query = query.filter(Expense.category == category)
|
||||
if project_id:
|
||||
query = query.filter(Expense.project_id == project_id)
|
||||
if start_date:
|
||||
try:
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Expense.expense_date >= start)
|
||||
except ValueError:
|
||||
pass
|
||||
if end_date:
|
||||
try:
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Expense.expense_date <= end)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
expenses = query.order_by(Expense.expense_date.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'expenses': [expense.to_dict() for expense in expenses],
|
||||
'count': len(expenses)
|
||||
})
|
||||
|
||||
|
||||
@expenses_bp.route('/api/expenses/<int:expense_id>', methods=['GET'])
|
||||
@login_required
|
||||
def api_get_expense(expense_id):
|
||||
"""API endpoint to get a single expense"""
|
||||
expense = Expense.query.get_or_404(expense_id)
|
||||
|
||||
# Check permission
|
||||
if not current_user.is_admin and expense.user_id != current_user.id:
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
return jsonify(expense.to_dict())
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<nav class="flex-1">
|
||||
{% set ep = request.endpoint or '' %}
|
||||
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') %}
|
||||
{% set insights_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('analytics.') %}
|
||||
{% set insights_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('analytics.') or ep.startswith('expenses.') %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider sidebar-label">{{ _('Navigation') }}</h2>
|
||||
<button id="sidebarCollapseBtn" class="p-1.5 rounded hover:bg-background-light dark:hover:bg-background-dark" aria-label="Toggle sidebar" title="Toggle sidebar">
|
||||
@@ -162,6 +162,7 @@
|
||||
<ul id="insightsDropdown" class="{% if not insights_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
||||
{% set nav_active_reports = ep.startswith('reports.') %}
|
||||
{% set nav_active_invoices = ep.startswith('invoices.') %}
|
||||
{% set nav_active_expenses = ep.startswith('expenses.') %}
|
||||
{% set nav_active_analytics = ep.startswith('analytics.') %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a>
|
||||
@@ -169,6 +170,9 @@
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoices.list_invoices') }}">{{ _('Invoices') }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_expenses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expenses.list_expenses') }}">{{ _('Expenses') }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_analytics %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('analytics.analytics_dashboard') }}">{{ _('Analytics') }}</a>
|
||||
</li>
|
||||
|
||||
252
app/templates/expenses/dashboard.html
Normal file
252
app/templates/expenses/dashboard.html
Normal file
@@ -0,0 +1,252 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Expenses', 'url': url_for('expenses.list_expenses')},
|
||||
{'text': 'Dashboard'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-chart-line',
|
||||
title_text='Expense Dashboard',
|
||||
subtitle_text='Overview of your expenses',
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<form method="GET" class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-search mr-2"></i>Update
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Total Expenses</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{{ total_expenses }}</p>
|
||||
</div>
|
||||
<div class="text-primary text-4xl">
|
||||
<i class="fas fa-file-invoice-dollar"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
|
||||
<p class="text-3xl font-bold text-green-600">{{ '€%.2f'|format(total_amount) }}</p>
|
||||
</div>
|
||||
<div class="text-green-500 text-4xl">
|
||||
<i class="fas fa-euro-sign"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Pending Approval</p>
|
||||
<p class="text-3xl font-bold text-yellow-600">{{ pending_count }}</p>
|
||||
</div>
|
||||
<div class="text-yellow-500 text-4xl">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Pending Reimbursement</p>
|
||||
<p class="text-3xl font-bold text-blue-600">{{ pending_reimbursement }}</p>
|
||||
</div>
|
||||
<div class="text-blue-500 text-4xl">
|
||||
<i class="fas fa-money-check-alt"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status & Category Breakdown -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Status Breakdown -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-check-circle mr-2"></i>By Status
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500 mr-3"></div>
|
||||
<span class="text-sm font-medium">Pending</span>
|
||||
</div>
|
||||
<span class="font-bold">{{ pending_count }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-green-500 mr-3"></div>
|
||||
<span class="text-sm font-medium">Approved</span>
|
||||
</div>
|
||||
<span class="font-bold">{{ approved_count }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500 mr-3"></div>
|
||||
<span class="text-sm font-medium">Rejected</span>
|
||||
</div>
|
||||
<span class="font-bold">{{ rejected_count }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-blue-500 mr-3"></div>
|
||||
<span class="text-sm font-medium">Reimbursed</span>
|
||||
</div>
|
||||
<span class="font-bold">{{ reimbursed_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Breakdown -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-tags mr-2"></i>By Category
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
{% if category_stats %}
|
||||
{% for stat in category_stats %}
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-tag text-primary mr-3"></i>
|
||||
<span class="text-sm font-medium">{{ stat.category|title }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-bold">{{ '€%.2f'|format(stat.total_amount) }}</div>
|
||||
<div class="text-xs text-gray-500">{{ stat.count }} items</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500 text-center py-4">No data available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Expenses -->
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<i class="fas fa-history mr-2"></i>Recent Expenses
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Title</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if recent_expenses %}
|
||||
{% for expense in recent_expenses %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ expense.expense_date.strftime('%Y-%m-%d') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:underline font-medium">
|
||||
{{ expense.title }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ expense.category|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{{ expense.currency_code }} {{ '%.2f'|format(expense.total_amount) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if expense.status == 'pending' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
Pending
|
||||
</span>
|
||||
{% elif expense.status == 'approved' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Approved
|
||||
</span>
|
||||
{% elif expense.status == 'rejected' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Rejected
|
||||
</span>
|
||||
{% elif expense.status == 'reimbursed' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Reimbursed
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:text-primary/80" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
|
||||
<i class="fas fa-receipt text-4xl mb-2 opacity-50"></i>
|
||||
<p>No expenses found</p>
|
||||
<a href="{{ url_for('expenses.create_expense') }}" class="text-primary hover:underline mt-2 inline-block">
|
||||
Create your first expense
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="{{ url_for('expenses.list_expenses') }}" class="text-primary hover:underline text-sm">
|
||||
View all expenses <i class="fas fa-arrow-right ml-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
347
app/templates/expenses/form.html
Normal file
347
app/templates/expenses/form.html
Normal file
@@ -0,0 +1,347 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Expenses', 'url': url_for('expenses.list_expenses')},
|
||||
{'text': 'Edit' if expense else 'New'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-receipt',
|
||||
title_text=('Edit Expense' if expense else 'New Expense'),
|
||||
subtitle_text=('Update expense details' if expense else 'Create a new expense record'),
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<form method="POST" enctype="multipart/form-data" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-info-circle mr-2"></i>Basic Information
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Title <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="title" id="title" required
|
||||
value="{{ expense.title if expense else '' }}"
|
||||
placeholder="e.g., Flight to Berlin"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea name="description" id="description" rows="3"
|
||||
placeholder="Additional details about the expense..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ expense.description if expense else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Category <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="category" id="category" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">Select Category</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if expense and expense.category == cat %}selected{% endif %}>
|
||||
{{ cat|title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="expense_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expense Date <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="date" name="expense_date" id="expense_date" required
|
||||
value="{{ expense.expense_date.strftime('%Y-%m-%d') if expense else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount Information -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-euro-sign mr-2"></i>Amount Details
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Amount <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="number" name="amount" id="amount" step="0.01" required
|
||||
value="{{ expense.amount if expense else '' }}"
|
||||
placeholder="0.00"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tax_amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tax Amount
|
||||
</label>
|
||||
<input type="number" name="tax_amount" id="tax_amount" step="0.01"
|
||||
value="{{ expense.tax_amount if expense else '0.00' }}"
|
||||
placeholder="0.00"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="currency_code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Currency
|
||||
</label>
|
||||
<select name="currency_code" id="currency_code"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="EUR" {% if not expense or expense.currency_code == 'EUR' %}selected{% endif %}>EUR</option>
|
||||
<option value="USD" {% if expense and expense.currency_code == 'USD' %}selected{% endif %}>USD</option>
|
||||
<option value="GBP" {% if expense and expense.currency_code == 'GBP' %}selected{% endif %}>GBP</option>
|
||||
<option value="CHF" {% if expense and expense.currency_code == 'CHF' %}selected{% endif %}>CHF</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
<strong>Total Amount:</strong> <span id="total_display">{{ (expense.amount + (expense.tax_amount or 0)) if expense else '0.00' }}</span> {{ expense.currency_code if expense else 'EUR' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project & Client -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-project-diagram mr-2"></i>Association
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Project
|
||||
</label>
|
||||
<select name="project_id" id="project_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">No Project</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}"
|
||||
data-client-id="{{ project.client_id if project.client_id else '' }}"
|
||||
{% if expense and expense.project_id == project.id %}selected{% endif %}>
|
||||
{{ project.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1"><i class="fas fa-info-circle"></i> Selecting a project will auto-fill the client</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Client
|
||||
</label>
|
||||
<select name="client_id" id="client_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">No Client</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}" {% if expense and expense.client_id == client.id %}selected{% endif %}>
|
||||
{{ client.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Information -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-credit-card mr-2"></i>Payment Details
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="payment_method" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Payment Method
|
||||
</label>
|
||||
<select name="payment_method" id="payment_method"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">Select Method</option>
|
||||
{% for method in payment_methods %}
|
||||
<option value="{{ method }}" {% if expense and expense.payment_method == method %}selected{% endif %}>
|
||||
{{ method.replace('_', ' ')|title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="payment_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Payment Date
|
||||
</label>
|
||||
<input type="date" name="payment_date" id="payment_date"
|
||||
value="{{ expense.payment_date.strftime('%Y-%m-%d') if expense and expense.payment_date else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="vendor" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Vendor
|
||||
</label>
|
||||
<input type="text" name="vendor" id="vendor"
|
||||
value="{{ expense.vendor if expense else '' }}"
|
||||
placeholder="e.g., Lufthansa"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receipt & Additional Info -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-file-alt mr-2"></i>Receipt & Additional Information
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="receipt_file" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Receipt File
|
||||
</label>
|
||||
<input type="file" name="receipt_file" id="receipt_file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.pdf"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<p class="text-xs text-gray-500 mt-1">Allowed: PNG, JPG, GIF, PDF (Max 10MB)</p>
|
||||
{% if expense and expense.receipt_path %}
|
||||
<p class="text-xs text-green-600 mt-1">
|
||||
<i class="fas fa-check-circle"></i> Receipt uploaded
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="receipt_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Receipt/Invoice Number
|
||||
</label>
|
||||
<input type="text" name="receipt_number" id="receipt_number"
|
||||
value="{{ expense.receipt_number if expense else '' }}"
|
||||
placeholder="e.g., INV-2024-001"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tags (comma-separated)
|
||||
</label>
|
||||
<input type="text" name="tags" id="tags"
|
||||
value="{{ expense.tags if expense else '' }}"
|
||||
placeholder="e.g., conference, client-meeting, urgent"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Notes
|
||||
</label>
|
||||
<textarea name="notes" id="notes" rows="3"
|
||||
placeholder="Additional notes..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ expense.notes if expense else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flags -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-toggle-on mr-2"></i>Options
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="billable" id="billable"
|
||||
{% if expense and expense.billable %}checked{% endif %}
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
|
||||
<label for="billable" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<strong>Billable to Client</strong>
|
||||
<span class="block text-xs text-gray-500">This expense can be billed to the client</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="reimbursable" id="reimbursable"
|
||||
{% if not expense or expense.reimbursable %}checked{% endif %}
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
|
||||
<label for="reimbursable" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<strong>Reimbursable</strong>
|
||||
<span class="block text-xs text-gray-500">Request reimbursement for this expense</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) if expense else url_for('expenses.list_expenses') }}"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-times mr-2"></i>Cancel
|
||||
</a>
|
||||
|
||||
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-save mr-2"></i>{{ 'Update Expense' if expense else 'Create Expense' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Calculate and display total amount
|
||||
function updateTotal() {
|
||||
const amount = parseFloat(document.getElementById('amount').value) || 0;
|
||||
const taxAmount = parseFloat(document.getElementById('tax_amount').value) || 0;
|
||||
const total = amount + taxAmount;
|
||||
const currency = document.getElementById('currency_code').value;
|
||||
document.getElementById('total_display').textContent = total.toFixed(2) + ' ' + currency;
|
||||
}
|
||||
|
||||
document.getElementById('amount').addEventListener('input', updateTotal);
|
||||
document.getElementById('tax_amount').addEventListener('input', updateTotal);
|
||||
document.getElementById('currency_code').addEventListener('change', updateTotal);
|
||||
|
||||
// Auto-select client when project is selected
|
||||
document.getElementById('project_id').addEventListener('change', function() {
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
const clientId = selectedOption.getAttribute('data-client-id');
|
||||
const clientSelect = document.getElementById('client_id');
|
||||
|
||||
if (clientId) {
|
||||
// Set the client dropdown to the project's client
|
||||
clientSelect.value = clientId;
|
||||
|
||||
// Visual feedback
|
||||
clientSelect.style.transition = 'all 0.3s ease';
|
||||
clientSelect.style.backgroundColor = '#d1fae5'; // Light green flash
|
||||
setTimeout(() => {
|
||||
clientSelect.style.backgroundColor = '';
|
||||
}, 1000);
|
||||
} else {
|
||||
// If project has no client or "No Project" is selected, clear client selection
|
||||
// But only if user hasn't manually selected a client
|
||||
// We'll be conservative and not clear it automatically
|
||||
}
|
||||
});
|
||||
|
||||
// Set default expense date to today if creating new
|
||||
{% if not expense %}
|
||||
document.getElementById('expense_date').value = new Date().toISOString().split('T')[0];
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
320
app/templates/expenses/list.html
Normal file
320
app/templates/expenses/list.html
Normal file
@@ -0,0 +1,320 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Expenses'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-receipt',
|
||||
title_text='Expenses',
|
||||
subtitle_text='Track and manage business expenses',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<a href="' + url_for("expenses.create_expense") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Expense</a>'
|
||||
) }}
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Total Expenses</p>
|
||||
<p class="text-2xl font-bold">{{ total_count }}</p>
|
||||
</div>
|
||||
<div class="text-primary text-3xl">
|
||||
<i class="fas fa-file-invoice-dollar"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
|
||||
<p class="text-2xl font-bold">{{ '€%.2f'|format(total_amount) }}</p>
|
||||
</div>
|
||||
<div class="text-green-500 text-3xl">
|
||||
<i class="fas fa-euro-sign"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<a href="{{ url_for('expenses.dashboard') }}" class="text-sm text-primary hover:underline">
|
||||
View Dashboard <i class="fas fa-arrow-right ml-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-blue-500 text-3xl">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Form -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Filter Expenses</h2>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
|
||||
<input type="text" name="search" id="search" value="{{ search or '' }}"
|
||||
placeholder="Title, vendor, notes..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
|
||||
<select name="status" id="status" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="approved" {% if status == 'approved' %}selected{% endif %}>Approved</option>
|
||||
<option value="rejected" {% if status == 'rejected' %}selected{% endif %}>Rejected</option>
|
||||
<option value="reimbursed" {% if status == 'reimbursed' %}selected{% endif %}>Reimbursed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
|
||||
<select name="category" id="category" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">All Categories</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if category == cat %}selected{% endif %}>{{ cat|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
|
||||
<select name="project_id" id="project_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">All Projects</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client</label>
|
||||
<select name="client_id" id="client_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">All Clients</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}" {% if client_id == client.id %}selected{% endif %}>{{ client.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin and users %}
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User</label>
|
||||
<select name="user_id" id="user_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if user_id == user.id %}selected{% endif %}>{{ user.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billable" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Billable</label>
|
||||
<select name="billable" id="billable" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
|
||||
<option value="">All</option>
|
||||
<option value="true" {% if billable == 'true' %}selected{% endif %}>Billable Only</option>
|
||||
<option value="false" {% if billable == 'false' %}selected{% endif %}>Non-Billable</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="submit" class="flex-1 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-filter mr-2"></i>Filter
|
||||
</button>
|
||||
<a href="{{ url_for('expenses.list_expenses') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('expenses.export_expenses', **request.args) }}" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors" title="Export to CSV">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Expenses Table -->
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Title</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">User</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Project</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if expenses %}
|
||||
{% for expense in expenses %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ expense.expense_date.strftime('%Y-%m-%d') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:underline font-medium">
|
||||
{{ expense.title }}
|
||||
</a>
|
||||
{% if expense.vendor %}
|
||||
<div class="text-xs text-gray-500">{{ expense.vendor }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ expense.category|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{{ expense.currency_code }} {{ '%.2f'|format(expense.total_amount) }}
|
||||
{% if expense.billable %}
|
||||
<span class="ml-1 text-xs text-green-600" title="Billable"><i class="fas fa-check-circle"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if expense.status == 'pending' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
Pending
|
||||
</span>
|
||||
{% elif expense.status == 'approved' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Approved
|
||||
</span>
|
||||
{% elif expense.status == 'rejected' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Rejected
|
||||
</span>
|
||||
{% elif expense.status == 'reimbursed' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Reimbursed
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ expense.user.username if expense.user else '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if expense.project %}
|
||||
<a href="{{ url_for('projects.view_project', project_id=expense.project_id) }}" class="text-primary hover:underline">
|
||||
{{ expense.project.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:text-primary/80" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if current_user.is_admin or expense.user_id == current_user.id %}
|
||||
<a href="{{ url_for('expenses.edit_expense', expense_id=expense.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-8 text-center text-gray-500">
|
||||
<i class="fas fa-receipt text-4xl mb-2 opacity-50"></i>
|
||||
<p>No expenses found</p>
|
||||
<a href="{{ url_for('expenses.create_expense') }}" class="text-primary hover:underline mt-2 inline-block">
|
||||
Create your first expense
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination and pagination.pages > 1 %}
|
||||
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-gray-700 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('expenses.list_expenses', page=pagination.prev_num, **request.args) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('expenses.list_expenses', page=pagination.next_num, **request.args) }}" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Next
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Showing <span class="font-medium">{{ ((pagination.page - 1) * pagination.per_page) + 1 }}</span>
|
||||
to <span class="font-medium">{{ min(pagination.page * pagination.per_page, pagination.total) }}</span>
|
||||
of <span class="font-medium">{{ pagination.total }}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('expenses.list_expenses', page=pagination.prev_num, **request.args) }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %}
|
||||
{% if page_num %}
|
||||
{% if page_num == pagination.page %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-primary bg-primary text-white text-sm font-medium">
|
||||
{{ page_num }}
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="{{ url_for('expenses.list_expenses', page=page_num, **request.args) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
...
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('expenses.list_expenses', page=pagination.next_num, **request.args) }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
396
app/templates/expenses/view.html
Normal file
396
app/templates/expenses/view.html
Normal file
@@ -0,0 +1,396 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Expenses', 'url': url_for('expenses.list_expenses')},
|
||||
{'text': expense.title}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-receipt',
|
||||
title_text=expense.title,
|
||||
subtitle_text='Expense Details',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<div class="flex gap-2">' +
|
||||
('<a href="' + url_for("expenses.edit_expense", expense_id=expense.id) + '" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>' if current_user.is_admin or expense.user_id == current_user.id else '') +
|
||||
'</div>'
|
||||
) }}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Details -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-info-circle mr-2"></i>Expense Information
|
||||
</h3>
|
||||
|
||||
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Date</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ expense.expense_date.strftime('%Y-%m-%d') }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Category</dt>
|
||||
<dd class="mt-1 text-sm">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ expense.category|title }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Amount</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ expense.currency_code }} {{ '%.2f'|format(expense.amount) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Tax Amount</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ expense.currency_code }} {{ '%.2f'|format(expense.tax_amount or 0) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||
<dt class="text-sm font-medium text-blue-800 dark:text-blue-200">Total Amount (incl. tax)</dt>
|
||||
<dd class="mt-1 text-xl font-bold text-blue-900 dark:text-blue-100">
|
||||
{{ expense.currency_code }} {{ '%.2f'|format(expense.total_amount) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{% if expense.description %}
|
||||
<div class="md:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Description</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ expense.description }}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
{% if expense.payment_method or expense.payment_date or expense.vendor or expense.receipt_number %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-credit-card mr-2"></i>Payment Details
|
||||
</h3>
|
||||
|
||||
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{% if expense.vendor %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Vendor</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ expense.vendor }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if expense.payment_method %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Payment Method</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ expense.payment_method.replace('_', ' ')|title }}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if expense.payment_date %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Payment Date</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ expense.payment_date.strftime('%Y-%m-%d') }}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if expense.receipt_number %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Receipt Number</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ expense.receipt_number }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Receipt -->
|
||||
{% if expense.receipt_path %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-file-alt mr-2"></i>Receipt
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-paperclip text-gray-400"></i>
|
||||
<a href="{{ url_for('static', filename='../' + expense.receipt_path) }}" target="_blank"
|
||||
class="text-primary hover:underline">
|
||||
View Receipt
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Notes -->
|
||||
{% if expense.notes %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<i class="fas fa-sticky-note mr-2"></i>Notes
|
||||
</h3>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap">{{ expense.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Approval Actions (for admin) -->
|
||||
{% if current_user.is_admin and expense.status == 'pending' %}
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 p-6 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<h3 class="text-lg font-semibold mb-4 text-yellow-800 dark:text-yellow-200">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>Pending Approval
|
||||
</h3>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<form method="POST" action="{{ url_for('expenses.approve_expense', expense_id=expense.id) }}" class="flex-1">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors">
|
||||
<i class="fas fa-check mr-2"></i>Approve
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button onclick="showRejectModal()" class="flex-1 bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
|
||||
<i class="fas fa-times mr-2"></i>Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Reimbursement Action (for admin on approved expenses) -->
|
||||
{% if current_user.is_admin and expense.status == 'approved' and expense.reimbursable and not expense.reimbursed %}
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h3 class="text-lg font-semibold mb-4 text-blue-800 dark:text-blue-200">
|
||||
<i class="fas fa-money-check-alt mr-2"></i>Reimbursement Pending
|
||||
</h3>
|
||||
|
||||
<form method="POST" action="{{ url_for('expenses.mark_reimbursed', expense_id=expense.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-check mr-2"></i>Mark as Reimbursed
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Status Card -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4">Status</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Approval Status</span>
|
||||
{% if expense.status == 'pending' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
Pending
|
||||
</span>
|
||||
{% elif expense.status == 'approved' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Approved
|
||||
</span>
|
||||
{% elif expense.status == 'rejected' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Rejected
|
||||
</span>
|
||||
{% elif expense.status == 'reimbursed' %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Reimbursed
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Billable</span>
|
||||
{% if expense.billable %}
|
||||
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400"><i class="fas fa-times-circle"></i> No</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Reimbursable</span>
|
||||
{% if expense.reimbursable %}
|
||||
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400"><i class="fas fa-times-circle"></i> No</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if expense.invoiced %}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Invoiced</span>
|
||||
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if expense.rejection_reason %}
|
||||
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<p class="text-sm font-medium text-red-800 dark:text-red-200 mb-1">Rejection Reason:</p>
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{{ expense.rejection_reason }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Association Card -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4">Associated With</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 block mb-1">User</span>
|
||||
<span class="text-sm font-medium">{{ expense.user.username if expense.user else '-' }}</span>
|
||||
</div>
|
||||
|
||||
{% if expense.project %}
|
||||
<div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 block mb-1">Project</span>
|
||||
<a href="{{ url_for('projects.view_project', project_id=expense.project_id) }}"
|
||||
class="text-sm font-medium text-primary hover:underline">
|
||||
{{ expense.project.name }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if expense.client %}
|
||||
<div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 block mb-1">Client</span>
|
||||
<a href="{{ url_for('clients.view_client', client_id=expense.client_id) }}"
|
||||
class="text-sm font-medium text-primary hover:underline">
|
||||
{{ expense.client.name }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if expense.approved_by %}
|
||||
<div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 block mb-1">Approved By</span>
|
||||
<span class="text-sm font-medium">{{ expense.approver.username if expense.approver else '-' }}</span>
|
||||
{% if expense.approved_at %}
|
||||
<div class="text-xs text-gray-500">{{ expense.approved_at.strftime('%Y-%m-%d %H:%M') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{% if expense.tags %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4">Tags</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for tag in expense.tag_list %}
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{{ tag }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4">Metadata</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Created:</span>
|
||||
<span class="text-gray-900 dark:text-gray-100">{{ expense.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Updated:</span>
|
||||
<span class="text-gray-900 dark:text-gray-100">{{ expense.updated_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{% if current_user.is_admin or expense.user_id == current_user.id %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4">Actions</h3>
|
||||
<div class="space-y-2">
|
||||
<a href="{{ url_for('expenses.edit_expense', expense_id=expense.id) }}"
|
||||
class="block w-full text-center bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-edit mr-2"></i>Edit Expense
|
||||
</a>
|
||||
|
||||
{% if current_user.is_admin or (expense.user_id == current_user.id and expense.status == 'pending') %}
|
||||
<form method="POST" action="{{ url_for('expenses.delete_expense', expense_id=expense.id) }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete this expense?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="block w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
|
||||
<i class="fas fa-trash mr-2"></i>Delete Expense
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rejection Modal -->
|
||||
<div id="rejectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold mb-4">Reject Expense</h3>
|
||||
<form method="POST" action="{{ url_for('expenses.reject_expense', expense_id=expense.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-4">
|
||||
<label for="rejection_reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Reason for rejection <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea name="rejection_reason" id="rejection_reason" rows="4" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700"
|
||||
placeholder="Explain why this expense is being rejected..."></textarea>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" onclick="hideRejectModal()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="flex-1 bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showRejectModal() {
|
||||
document.getElementById('rejectModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideRejectModal() {
|
||||
document.getElementById('rejectModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
hideRejectModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on backdrop click
|
||||
document.getElementById('rejectModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
hideRejectModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
493
docs/EXPENSE_TRACKING.md
Normal file
493
docs/EXPENSE_TRACKING.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# Expense Tracking Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Expense Tracking feature allows users to record, manage, and track business expenses within the TimeTracker application. This comprehensive system includes expense creation, approval workflows, reimbursement tracking, and integration with projects, clients, and invoicing.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Features](#features)
|
||||
2. [User Roles and Permissions](#user-roles-and-permissions)
|
||||
3. [Creating Expenses](#creating-expenses)
|
||||
4. [Approval Workflow](#approval-workflow)
|
||||
5. [Reimbursement Process](#reimbursement-process)
|
||||
6. [Expense Categories](#expense-categories)
|
||||
7. [Filtering and Search](#filtering-and-search)
|
||||
8. [Export and Reporting](#export-and-reporting)
|
||||
9. [Integration](#integration)
|
||||
10. [API Endpoints](#api-endpoints)
|
||||
11. [Database Schema](#database-schema)
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
|
||||
- **Expense Recording**: Track expenses with detailed information including amount, category, vendor, and receipts
|
||||
- **Multi-Currency Support**: Record expenses in different currencies (EUR, USD, GBP, CHF)
|
||||
- **Tax Tracking**: Separate tax amount tracking for accurate financial reporting
|
||||
- **Receipt Management**: Upload and attach receipt files to expenses
|
||||
- **Approval Workflow**: Multi-stage approval process with admin oversight
|
||||
- **Reimbursement Tracking**: Track which expenses have been reimbursed
|
||||
- **Billable Expenses**: Mark expenses as billable to clients
|
||||
- **Project/Client Association**: Link expenses to specific projects and clients
|
||||
- **Tags and Notes**: Add tags and detailed notes for better organization
|
||||
- **Dashboard Analytics**: Visual analytics and summaries of expense data
|
||||
- **Export Functionality**: Export expense data to CSV format
|
||||
|
||||
### Advanced Features
|
||||
|
||||
- **Status Tracking**: Track expenses through pending, approved, rejected, and reimbursed states
|
||||
- **Date Range Filtering**: Filter expenses by date ranges
|
||||
- **Category Analytics**: View spending breakdown by category
|
||||
- **Payment Method Tracking**: Record payment methods used for expenses
|
||||
- **Bulk Operations**: Perform operations on multiple expenses efficiently
|
||||
- **Integration with Invoicing**: Link billable expenses to client invoices
|
||||
|
||||
## User Roles and Permissions
|
||||
|
||||
### Regular Users
|
||||
|
||||
**Can:**
|
||||
- Create new expenses
|
||||
- View their own expenses
|
||||
- Edit pending expenses they created
|
||||
- Delete their own pending expenses
|
||||
- Add receipts and documentation
|
||||
- View expense status and approval information
|
||||
|
||||
**Cannot:**
|
||||
- Approve or reject expenses
|
||||
- Mark expenses as reimbursed
|
||||
- View other users' expenses
|
||||
- Edit approved or reimbursed expenses
|
||||
|
||||
### Admin Users
|
||||
|
||||
**Can:**
|
||||
- All regular user permissions
|
||||
- View all expenses from all users
|
||||
- Approve or reject pending expenses
|
||||
- Mark expenses as reimbursed
|
||||
- Edit any expense regardless of status
|
||||
- Delete any expense
|
||||
- Access full expense analytics dashboard
|
||||
|
||||
## Creating Expenses
|
||||
|
||||
### Basic Expense Creation
|
||||
|
||||
1. Navigate to **Insights → Expenses** in the sidebar
|
||||
2. Click **New Expense** button
|
||||
3. Fill in required fields:
|
||||
- **Title**: Short description of the expense
|
||||
- **Category**: Select from predefined categories
|
||||
- **Amount**: Expense amount (excluding tax)
|
||||
- **Expense Date**: Date the expense was incurred
|
||||
|
||||
### Optional Fields
|
||||
|
||||
- **Description**: Detailed description of the expense
|
||||
- **Tax Amount**: Separate tax amount
|
||||
- **Currency**: Currency code (default: EUR)
|
||||
- **Project**: Associate with a project
|
||||
- **Client**: Associate with a client
|
||||
- **Payment Method**: How the expense was paid
|
||||
- **Payment Date**: When payment was made
|
||||
- **Vendor**: Name of the vendor/supplier
|
||||
- **Receipt Number**: Receipt or invoice number
|
||||
- **Receipt File**: Upload receipt image or PDF
|
||||
- **Tags**: Comma-separated tags for organization
|
||||
- **Notes**: Additional notes
|
||||
- **Billable**: Mark if expense should be billed to client
|
||||
- **Reimbursable**: Mark if expense should be reimbursed
|
||||
|
||||
### Example: Creating a Travel Expense
|
||||
|
||||
```
|
||||
Title: Flight to Berlin Client Meeting
|
||||
Description: Round-trip flight for Q4 business review
|
||||
Category: Travel
|
||||
Amount: 450.00
|
||||
Tax Amount: 45.00
|
||||
Currency: EUR
|
||||
Expense Date: 2025-10-20
|
||||
Payment Method: Company Card
|
||||
Vendor: Lufthansa
|
||||
Project: [Select Project]
|
||||
Client: [Select Client]
|
||||
Billable: ✓ (checked)
|
||||
Reimbursable: ✗ (unchecked)
|
||||
Tags: travel, client-meeting, Q4
|
||||
```
|
||||
|
||||
## Approval Workflow
|
||||
|
||||
### States
|
||||
|
||||
1. **Pending**: Newly created expense awaiting approval
|
||||
2. **Approved**: Expense approved by admin
|
||||
3. **Rejected**: Expense rejected with reason
|
||||
4. **Reimbursed**: Approved expense that has been reimbursed
|
||||
|
||||
### Approval Process
|
||||
|
||||
#### For Users:
|
||||
1. Create expense with all required information
|
||||
2. Submit expense (automatically set to "Pending" status)
|
||||
3. Wait for admin review
|
||||
4. Receive notification of approval or rejection
|
||||
5. If approved and reimbursable, wait for reimbursement
|
||||
|
||||
#### For Admins:
|
||||
1. Navigate to expense list
|
||||
2. Filter by status: "Pending"
|
||||
3. Click on expense to view details
|
||||
4. Review all information, receipts, and documentation
|
||||
5. Choose action:
|
||||
- **Approve**: Approves the expense (optionally add approval notes)
|
||||
- **Reject**: Rejects the expense (must provide rejection reason)
|
||||
|
||||
### Rejection Reasons
|
||||
|
||||
When rejecting an expense, admins must provide a clear reason:
|
||||
- Missing or invalid receipt
|
||||
- Expense not covered by company policy
|
||||
- Incorrect category
|
||||
- Amount exceeds limit
|
||||
- Duplicate expense
|
||||
- Other (with explanation)
|
||||
|
||||
## Reimbursement Process
|
||||
|
||||
### For Reimbursable Expenses
|
||||
|
||||
1. User creates expense and marks it as "Reimbursable"
|
||||
2. Admin approves the expense
|
||||
3. Finance processes reimbursement outside the system
|
||||
4. Admin marks expense as "Reimbursed" in the system
|
||||
5. Expense status changes to "Reimbursed" with timestamp
|
||||
|
||||
### Tracking Reimbursements
|
||||
|
||||
- Dashboard shows count of pending reimbursements
|
||||
- Filter expenses by reimbursement status
|
||||
- View reimbursement date and details
|
||||
- Export reimbursement reports
|
||||
|
||||
## Expense Categories
|
||||
|
||||
The system provides predefined expense categories:
|
||||
|
||||
- **Travel**: Flights, trains, taxis, car rentals
|
||||
- **Meals**: Business meals, client entertainment
|
||||
- **Accommodation**: Hotels, short-term rentals
|
||||
- **Supplies**: Office supplies, materials
|
||||
- **Software**: Software licenses, subscriptions
|
||||
- **Equipment**: Hardware, tools, equipment purchases
|
||||
- **Services**: Professional services, consultants
|
||||
- **Marketing**: Advertising, promotional materials
|
||||
- **Training**: Courses, conferences, professional development
|
||||
- **Other**: Miscellaneous expenses
|
||||
|
||||
### Category Analytics
|
||||
|
||||
View spending breakdown by category:
|
||||
- Total amount per category
|
||||
- Number of expenses per category
|
||||
- Percentage of total spending
|
||||
- Trend analysis over time
|
||||
|
||||
## Filtering and Search
|
||||
|
||||
### Available Filters
|
||||
|
||||
- **Search**: Search by title, vendor, notes, or description
|
||||
- **Status**: Filter by approval status (pending, approved, rejected, reimbursed)
|
||||
- **Category**: Filter by expense category
|
||||
- **Project**: Filter by associated project
|
||||
- **Client**: Filter by associated client
|
||||
- **User**: (Admin only) Filter by user who created expense
|
||||
- **Date Range**: Filter by expense date range
|
||||
- **Billable**: Filter billable/non-billable expenses
|
||||
- **Reimbursable**: Filter reimbursable/non-reimbursable expenses
|
||||
|
||||
### Search Examples
|
||||
|
||||
```
|
||||
Search: "conference"
|
||||
Status: Approved
|
||||
Category: Travel
|
||||
Date Range: 2025-01-01 to 2025-03-31
|
||||
Billable: Yes
|
||||
```
|
||||
|
||||
## Export and Reporting
|
||||
|
||||
### CSV Export
|
||||
|
||||
Export filtered expenses to CSV format including:
|
||||
- Date
|
||||
- Title
|
||||
- Category
|
||||
- Amount
|
||||
- Tax
|
||||
- Total
|
||||
- Currency
|
||||
- Status
|
||||
- Vendor
|
||||
- Payment Method
|
||||
- Project
|
||||
- Client
|
||||
- User
|
||||
- Billable flag
|
||||
- Reimbursable flag
|
||||
- Invoiced flag
|
||||
- Receipt number
|
||||
- Notes
|
||||
|
||||
### Dashboard Analytics
|
||||
|
||||
The expense dashboard provides:
|
||||
- Total expense count and amount for date range
|
||||
- Pending approval count
|
||||
- Pending reimbursement count
|
||||
- Status breakdown (pending, approved, rejected, reimbursed)
|
||||
- Category breakdown with amounts
|
||||
- Recent expenses list
|
||||
- Visual charts and graphs
|
||||
|
||||
### Accessing the Dashboard
|
||||
|
||||
1. Navigate to **Insights → Expenses**
|
||||
2. Click **View Dashboard** in the summary card
|
||||
3. Adjust date range as needed
|
||||
4. View analytics and statistics
|
||||
|
||||
## Integration
|
||||
|
||||
### With Projects
|
||||
|
||||
- Associate expenses with specific projects
|
||||
- View project-specific expense totals
|
||||
- Include expenses in project cost analysis
|
||||
- Track billable vs. non-billable project expenses
|
||||
|
||||
### With Clients
|
||||
|
||||
- Link expenses to client accounts
|
||||
- Generate client-specific expense reports
|
||||
- Include billable expenses in client invoices
|
||||
- Track client-related spending
|
||||
|
||||
### With Invoicing
|
||||
|
||||
- Mark expenses as billable to clients
|
||||
- Track which expenses have been invoiced
|
||||
- Link expenses to specific invoices
|
||||
- Automatically include billable expenses in invoice generation
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### List Expenses
|
||||
|
||||
```
|
||||
GET /api/expenses
|
||||
Query Parameters:
|
||||
- status: Filter by status
|
||||
- category: Filter by category
|
||||
- project_id: Filter by project
|
||||
- start_date: Start date (YYYY-MM-DD)
|
||||
- end_date: End date (YYYY-MM-DD)
|
||||
|
||||
Response:
|
||||
{
|
||||
"expenses": [...],
|
||||
"count": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Get Single Expense
|
||||
|
||||
```
|
||||
GET /api/expenses/<expense_id>
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Travel Expense",
|
||||
"category": "travel",
|
||||
"amount": 150.00,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Create Expense (via Web Form)
|
||||
|
||||
```
|
||||
POST /expenses/create
|
||||
Form Data:
|
||||
- title: string (required)
|
||||
- category: string (required)
|
||||
- amount: decimal (required)
|
||||
- expense_date: date (required)
|
||||
- [additional optional fields]
|
||||
```
|
||||
|
||||
### Approve Expense
|
||||
|
||||
```
|
||||
POST /expenses/<expense_id>/approve
|
||||
Form Data:
|
||||
- approval_notes: string (optional)
|
||||
```
|
||||
|
||||
### Reject Expense
|
||||
|
||||
```
|
||||
POST /expenses/<expense_id>/reject
|
||||
Form Data:
|
||||
- rejection_reason: string (required)
|
||||
```
|
||||
|
||||
### Mark as Reimbursed
|
||||
|
||||
```
|
||||
POST /expenses/<expense_id>/reimburse
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Expenses Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE expenses (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
project_id INTEGER,
|
||||
client_id INTEGER,
|
||||
|
||||
-- Expense details
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
amount NUMERIC(10, 2) NOT NULL,
|
||||
currency_code VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
tax_amount NUMERIC(10, 2),
|
||||
tax_rate NUMERIC(5, 2),
|
||||
|
||||
-- Payment information
|
||||
payment_method VARCHAR(50),
|
||||
payment_date DATE,
|
||||
|
||||
-- Status and approval
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
approved_by INTEGER,
|
||||
approved_at DATETIME,
|
||||
rejection_reason TEXT,
|
||||
|
||||
-- Billing and invoicing
|
||||
billable BOOLEAN NOT NULL DEFAULT 0,
|
||||
reimbursable BOOLEAN NOT NULL DEFAULT 1,
|
||||
invoiced BOOLEAN NOT NULL DEFAULT 0,
|
||||
invoice_id INTEGER,
|
||||
reimbursed BOOLEAN NOT NULL DEFAULT 0,
|
||||
reimbursed_at DATETIME,
|
||||
|
||||
-- Date and metadata
|
||||
expense_date DATE NOT NULL,
|
||||
receipt_path VARCHAR(500),
|
||||
receipt_number VARCHAR(100),
|
||||
vendor VARCHAR(200),
|
||||
notes TEXT,
|
||||
tags VARCHAR(500),
|
||||
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX ix_expenses_user_id ON expenses(user_id);
|
||||
CREATE INDEX ix_expenses_project_id ON expenses(project_id);
|
||||
CREATE INDEX ix_expenses_client_id ON expenses(client_id);
|
||||
CREATE INDEX ix_expenses_expense_date ON expenses(expense_date);
|
||||
CREATE INDEX ix_expenses_user_date ON expenses(user_id, expense_date);
|
||||
CREATE INDEX ix_expenses_status_date ON expenses(status, expense_date);
|
||||
CREATE INDEX ix_expenses_project_date ON expenses(project_id, expense_date);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Users
|
||||
|
||||
1. **Be Detailed**: Provide clear titles and descriptions
|
||||
2. **Attach Receipts**: Always upload receipt documentation
|
||||
3. **Timely Submission**: Submit expenses promptly while details are fresh
|
||||
4. **Accurate Categorization**: Choose the most appropriate category
|
||||
5. **Complete Information**: Fill in all relevant optional fields
|
||||
6. **Project Association**: Link to projects when applicable
|
||||
7. **Tag Appropriately**: Use tags for easier searching and filtering
|
||||
|
||||
### For Admins
|
||||
|
||||
1. **Prompt Review**: Review expenses in a timely manner
|
||||
2. **Clear Communication**: Provide detailed reasons for rejections
|
||||
3. **Consistent Policy**: Apply expense policies consistently
|
||||
4. **Documentation Check**: Verify receipt documentation before approval
|
||||
5. **Amount Verification**: Verify amounts match receipts
|
||||
6. **Policy Compliance**: Ensure expenses comply with company policy
|
||||
7. **Regular Audits**: Periodically audit expense patterns
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Problem**: Can't upload receipt file
|
||||
- **Solution**: Ensure file is PNG, JPG, GIF, or PDF format under 10MB
|
||||
|
||||
**Problem**: Can't edit approved expense
|
||||
- **Solution**: Only admins can edit approved expenses. Contact admin if changes needed.
|
||||
|
||||
**Problem**: Expense not showing in project costs
|
||||
- **Solution**: Ensure expense is linked to the project and approved
|
||||
|
||||
**Problem**: Can't delete expense
|
||||
- **Solution**: Only pending expenses can be deleted by regular users
|
||||
|
||||
**Problem**: Total amount calculation seems wrong
|
||||
- **Solution**: Check that tax amount is entered correctly; total = amount + tax
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned features for future releases:
|
||||
- Automated expense import from credit card statements
|
||||
- Mobile app for expense submission
|
||||
- OCR for automatic receipt data extraction
|
||||
- Approval routing based on amount thresholds
|
||||
- Multi-level approval workflows
|
||||
- Expense budget tracking and alerts
|
||||
- Mileage tracking and calculation
|
||||
- Per diem calculations
|
||||
- Corporate card integration
|
||||
- Real-time currency conversion
|
||||
|
||||
## Support
|
||||
|
||||
For questions or issues with the Expense Tracking feature:
|
||||
- Check this documentation
|
||||
- Review inline help text in the application
|
||||
- Contact your system administrator
|
||||
- Check the application logs for error details
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Invoicing Guide](./INVOICING.md)
|
||||
- [Project Cost Tracking](./PROJECT_COSTS.md)
|
||||
- [User Roles and Permissions](./PERMISSIONS.md)
|
||||
- [API Documentation](./API.md)
|
||||
|
||||
188
migrations/versions/029_add_expenses_table.py
Normal file
188
migrations/versions/029_add_expenses_table.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Add expenses table for expense tracking
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2025-10-24 00:00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '029'
|
||||
down_revision = '028'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(inspector, name: str) -> bool:
|
||||
"""Check if a table exists"""
|
||||
try:
|
||||
return name in inspector.get_table_names()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create expenses table"""
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Determine database dialect for proper default values
|
||||
dialect_name = bind.dialect.name
|
||||
print(f"[Migration 029] Running on {dialect_name} database")
|
||||
|
||||
# Set appropriate boolean defaults based on database
|
||||
if dialect_name == 'sqlite':
|
||||
bool_true_default = '1'
|
||||
bool_false_default = '0'
|
||||
timestamp_default = "(datetime('now'))"
|
||||
elif dialect_name == 'postgresql':
|
||||
bool_true_default = 'true'
|
||||
bool_false_default = 'false'
|
||||
timestamp_default = 'CURRENT_TIMESTAMP'
|
||||
else: # MySQL/MariaDB and others
|
||||
bool_true_default = '1'
|
||||
bool_false_default = '0'
|
||||
timestamp_default = 'CURRENT_TIMESTAMP'
|
||||
|
||||
# Create expenses table if it doesn't exist
|
||||
if not _has_table(inspector, 'expenses'):
|
||||
print("[Migration 029] Creating expenses table...")
|
||||
try:
|
||||
# Check if related tables exist for conditional FKs
|
||||
has_projects = _has_table(inspector, 'projects')
|
||||
has_clients = _has_table(inspector, 'clients')
|
||||
has_invoices = _has_table(inspector, 'invoices')
|
||||
has_users = _has_table(inspector, 'users')
|
||||
|
||||
# Build foreign key constraints
|
||||
fk_constraints = []
|
||||
|
||||
if has_users:
|
||||
fk_constraints.extend([
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='fk_expenses_user_id', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['approved_by'], ['users.id'], name='fk_expenses_approved_by', ondelete='SET NULL'),
|
||||
])
|
||||
print("[Migration 029] Including user FKs")
|
||||
else:
|
||||
print("[Migration 029] ⚠ Skipping user FKs (users table doesn't exist)")
|
||||
|
||||
if has_projects:
|
||||
fk_constraints.append(
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='fk_expenses_project_id', ondelete='SET NULL')
|
||||
)
|
||||
print("[Migration 029] Including project_id FK")
|
||||
else:
|
||||
print("[Migration 029] ⚠ Skipping project_id FK (projects table doesn't exist)")
|
||||
|
||||
if has_clients:
|
||||
fk_constraints.append(
|
||||
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], name='fk_expenses_client_id', ondelete='SET NULL')
|
||||
)
|
||||
print("[Migration 029] Including client_id FK")
|
||||
else:
|
||||
print("[Migration 029] ⚠ Skipping client_id FK (clients table doesn't exist)")
|
||||
|
||||
if has_invoices:
|
||||
fk_constraints.append(
|
||||
sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], name='fk_expenses_invoice_id', ondelete='SET NULL')
|
||||
)
|
||||
print("[Migration 029] Including invoice_id FK")
|
||||
else:
|
||||
print("[Migration 029] ⚠ Skipping invoice_id FK (invoices table doesn't exist)")
|
||||
|
||||
op.create_table(
|
||||
'expenses',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('project_id', sa.Integer(), nullable=True),
|
||||
sa.Column('client_id', sa.Integer(), nullable=True),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('category', sa.String(length=50), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'),
|
||||
sa.Column('tax_amount', sa.Numeric(precision=10, scale=2), nullable=True, server_default='0'),
|
||||
sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, server_default='0'),
|
||||
sa.Column('payment_method', sa.String(length=50), nullable=True),
|
||||
sa.Column('payment_date', sa.Date(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
|
||||
sa.Column('approved_by', sa.Integer(), nullable=True),
|
||||
sa.Column('approved_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('rejection_reason', sa.Text(), nullable=True),
|
||||
sa.Column('billable', sa.Boolean(), nullable=False, server_default=sa.text(bool_false_default)),
|
||||
sa.Column('reimbursable', sa.Boolean(), nullable=False, server_default=sa.text(bool_true_default)),
|
||||
sa.Column('invoiced', sa.Boolean(), nullable=False, server_default=sa.text(bool_false_default)),
|
||||
sa.Column('invoice_id', sa.Integer(), nullable=True),
|
||||
sa.Column('reimbursed', sa.Boolean(), nullable=False, server_default=sa.text(bool_false_default)),
|
||||
sa.Column('reimbursed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('expense_date', sa.Date(), nullable=False),
|
||||
sa.Column('receipt_path', sa.String(length=500), nullable=True),
|
||||
sa.Column('receipt_number', sa.String(length=100), nullable=True),
|
||||
sa.Column('vendor', sa.String(length=200), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('tags', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text(timestamp_default)),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text(timestamp_default)),
|
||||
*fk_constraints # Include FKs during table creation for SQLite compatibility
|
||||
)
|
||||
print("[Migration 029] ✓ Table created with foreign keys")
|
||||
except Exception as e:
|
||||
print(f"[Migration 029] ✗ Error creating table: {e}")
|
||||
raise
|
||||
|
||||
# Create indexes
|
||||
print("[Migration 029] Creating indexes...")
|
||||
try:
|
||||
op.create_index('ix_expenses_user_id', 'expenses', ['user_id'])
|
||||
op.create_index('ix_expenses_project_id', 'expenses', ['project_id'])
|
||||
op.create_index('ix_expenses_client_id', 'expenses', ['client_id'])
|
||||
op.create_index('ix_expenses_approved_by', 'expenses', ['approved_by'])
|
||||
op.create_index('ix_expenses_invoice_id', 'expenses', ['invoice_id'])
|
||||
op.create_index('ix_expenses_expense_date', 'expenses', ['expense_date'])
|
||||
|
||||
# Composite indexes for common query patterns
|
||||
op.create_index('ix_expenses_user_date', 'expenses', ['user_id', 'expense_date'])
|
||||
op.create_index('ix_expenses_status_date', 'expenses', ['status', 'expense_date'])
|
||||
op.create_index('ix_expenses_project_date', 'expenses', ['project_id', 'expense_date'])
|
||||
|
||||
print("[Migration 029] ✓ Indexes created")
|
||||
except Exception as e:
|
||||
print(f"[Migration 029] ✗ Error creating indexes: {e}")
|
||||
raise
|
||||
|
||||
print("[Migration 029] ✓ Migration completed successfully")
|
||||
else:
|
||||
print("[Migration 029] ⚠ Table already exists, skipping")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop expenses table"""
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _has_table(inspector, 'expenses'):
|
||||
print("[Migration 029] Dropping expenses table...")
|
||||
try:
|
||||
# Drop indexes first
|
||||
try:
|
||||
op.drop_index('ix_expenses_project_date', 'expenses')
|
||||
op.drop_index('ix_expenses_status_date', 'expenses')
|
||||
op.drop_index('ix_expenses_user_date', 'expenses')
|
||||
op.drop_index('ix_expenses_expense_date', 'expenses')
|
||||
op.drop_index('ix_expenses_invoice_id', 'expenses')
|
||||
op.drop_index('ix_expenses_approved_by', 'expenses')
|
||||
op.drop_index('ix_expenses_client_id', 'expenses')
|
||||
op.drop_index('ix_expenses_project_id', 'expenses')
|
||||
op.drop_index('ix_expenses_user_id', 'expenses')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drop table
|
||||
op.drop_table('expenses')
|
||||
print("[Migration 029] ✓ Table dropped")
|
||||
except Exception as e:
|
||||
print(f"[Migration 029] ✗ Error dropping table: {e}")
|
||||
|
||||
716
tests/test_expenses.py
Normal file
716
tests/test_expenses.py
Normal file
@@ -0,0 +1,716 @@
|
||||
"""
|
||||
Comprehensive tests for Expense model and related functionality.
|
||||
|
||||
This module tests:
|
||||
- Expense model creation and validation
|
||||
- Relationships with User, Project, Client, and Invoice models
|
||||
- Query methods (get_expenses, get_total_expenses, etc.)
|
||||
- Approval and reimbursement workflows
|
||||
- Data integrity and constraints
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from app import create_app, db
|
||||
from app.models import User, Project, Client, Invoice, Expense
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create and configure a test application instance."""
|
||||
app = create_app({
|
||||
'TESTING': True,
|
||||
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
|
||||
'WTF_CSRF_ENABLED': False
|
||||
})
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_fixture(app):
|
||||
"""Create a test Flask client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(app):
|
||||
"""Create a test user."""
|
||||
with app.app_context():
|
||||
user = User(username='testuser', role='user')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_admin(app):
|
||||
"""Create a test admin user."""
|
||||
with app.app_context():
|
||||
admin = User(username='admin', role='admin')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
return admin.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(app):
|
||||
"""Create a test client."""
|
||||
with app.app_context():
|
||||
client = Client(name='Test Client', description='A test client')
|
||||
db.session.add(client)
|
||||
db.session.commit()
|
||||
return client.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project(app, test_client):
|
||||
"""Create a test project."""
|
||||
with app.app_context():
|
||||
project = Project(
|
||||
name='Test Project',
|
||||
client_id=test_client,
|
||||
description='A test project',
|
||||
billable=True,
|
||||
hourly_rate=Decimal('100.00')
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return project.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_invoice(app, test_client, test_project, test_user):
|
||||
"""Create a test invoice."""
|
||||
with app.app_context():
|
||||
client = db.session.get(Client, test_client)
|
||||
invoice = Invoice(
|
||||
invoice_number='INV-TEST-001',
|
||||
project_id=test_project,
|
||||
client_name=client.name,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=test_user,
|
||||
client_id=test_client,
|
||||
issue_date=date.today(),
|
||||
status='draft'
|
||||
)
|
||||
db.session.add(invoice)
|
||||
db.session.commit()
|
||||
return invoice.id
|
||||
|
||||
|
||||
# Model Tests
|
||||
|
||||
class TestExpenseModel:
|
||||
"""Test Expense model creation, validation, and basic operations."""
|
||||
|
||||
def test_create_expense(self, app, test_user):
|
||||
"""Test creating a basic expense."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Travel Expense',
|
||||
category='travel',
|
||||
amount=Decimal('150.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
assert expense.id is not None
|
||||
assert expense.title == 'Travel Expense'
|
||||
assert expense.category == 'travel'
|
||||
assert expense.amount == Decimal('150.00')
|
||||
assert expense.currency_code == 'EUR'
|
||||
assert expense.status == 'pending'
|
||||
assert expense.billable is False
|
||||
assert expense.reimbursable is True
|
||||
|
||||
def test_create_expense_with_all_fields(self, app, test_user, test_project, test_client):
|
||||
"""Test creating an expense with all optional fields."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Conference Travel',
|
||||
category='travel',
|
||||
amount=Decimal('500.00'),
|
||||
expense_date=date.today(),
|
||||
description='Flight and hotel for conference',
|
||||
project_id=test_project,
|
||||
client_id=test_client,
|
||||
currency_code='USD',
|
||||
tax_amount=Decimal('50.00'),
|
||||
payment_method='credit_card',
|
||||
payment_date=date.today(),
|
||||
vendor='Airline Inc',
|
||||
receipt_number='REC-2024-001',
|
||||
notes='Business class flight',
|
||||
tags='conference,travel,urgent',
|
||||
billable=True,
|
||||
reimbursable=True
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
assert expense.description == 'Flight and hotel for conference'
|
||||
assert expense.project_id == test_project
|
||||
assert expense.client_id == test_client
|
||||
assert expense.currency_code == 'USD'
|
||||
assert expense.tax_amount == Decimal('50.00')
|
||||
assert expense.vendor == 'Airline Inc'
|
||||
assert expense.billable is True
|
||||
|
||||
def test_expense_str_representation(self, app, test_user):
|
||||
"""Test __repr__ method."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Office Supplies',
|
||||
category='supplies',
|
||||
amount=Decimal('75.50'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
assert 'Office Supplies' in str(expense)
|
||||
assert 'EUR' in str(expense)
|
||||
|
||||
def test_expense_timestamps(self, app, test_user):
|
||||
"""Test automatic timestamp creation."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='other',
|
||||
amount=Decimal('10.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
assert expense.created_at is not None
|
||||
assert expense.updated_at is not None
|
||||
assert isinstance(expense.created_at, datetime)
|
||||
assert isinstance(expense.updated_at, datetime)
|
||||
|
||||
|
||||
class TestExpenseProperties:
|
||||
"""Test Expense computed properties."""
|
||||
|
||||
def test_total_amount_property(self, app, test_user):
|
||||
"""Test total_amount property including tax."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
tax_amount=Decimal('10.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
assert expense.total_amount == Decimal('110.00')
|
||||
|
||||
def test_tag_list_property(self, app, test_user):
|
||||
"""Test tag_list property parsing."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today(),
|
||||
tags='urgent, client-meeting, conference'
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
tags = expense.tag_list
|
||||
assert len(tags) == 3
|
||||
assert 'urgent' in tags
|
||||
assert 'client-meeting' in tags
|
||||
assert 'conference' in tags
|
||||
|
||||
def test_is_approved_property(self, app, test_user, test_admin):
|
||||
"""Test is_approved property."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
# Initially not approved
|
||||
assert expense.is_approved is False
|
||||
|
||||
# Approve
|
||||
expense.approve(test_admin)
|
||||
db.session.commit()
|
||||
|
||||
assert expense.is_approved is True
|
||||
|
||||
def test_is_reimbursed_property(self, app, test_user):
|
||||
"""Test is_reimbursed property."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
assert expense.is_reimbursed is False
|
||||
|
||||
expense.mark_as_reimbursed()
|
||||
db.session.commit()
|
||||
|
||||
assert expense.is_reimbursed is True
|
||||
|
||||
|
||||
class TestExpenseRelationships:
|
||||
"""Test Expense relationships with other models."""
|
||||
|
||||
def test_user_relationship(self, app, test_user):
|
||||
"""Test relationship with User model."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
expense = db.session.get(Expense, expense.id)
|
||||
user = db.session.get(User, test_user)
|
||||
|
||||
assert expense.user is not None
|
||||
assert expense.user.id == test_user
|
||||
assert expense in user.expenses.all()
|
||||
|
||||
def test_project_relationship(self, app, test_user, test_project):
|
||||
"""Test relationship with Project model."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today(),
|
||||
project_id=test_project
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
expense = db.session.get(Expense, expense.id)
|
||||
project = db.session.get(Project, test_project)
|
||||
|
||||
assert expense.project is not None
|
||||
assert expense.project.id == test_project
|
||||
assert expense in project.expenses.all()
|
||||
|
||||
def test_client_relationship(self, app, test_user, test_client):
|
||||
"""Test relationship with Client model."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today(),
|
||||
client_id=test_client
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
expense = db.session.get(Expense, expense.id)
|
||||
client = db.session.get(Client, test_client)
|
||||
|
||||
assert expense.client is not None
|
||||
assert expense.client.id == test_client
|
||||
assert expense in client.expenses.all()
|
||||
|
||||
|
||||
class TestExpenseMethods:
|
||||
"""Test Expense instance and class methods."""
|
||||
|
||||
def test_approve_method(self, app, test_user, test_admin):
|
||||
"""Test approving an expense."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
expense.approve(test_admin, notes='Approved for reimbursement')
|
||||
db.session.commit()
|
||||
|
||||
assert expense.status == 'approved'
|
||||
assert expense.approved_by == test_admin
|
||||
assert expense.approved_at is not None
|
||||
|
||||
def test_reject_method(self, app, test_user, test_admin):
|
||||
"""Test rejecting an expense."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
expense.reject(test_admin, 'Receipt not provided')
|
||||
db.session.commit()
|
||||
|
||||
assert expense.status == 'rejected'
|
||||
assert expense.approved_by == test_admin
|
||||
assert expense.rejection_reason == 'Receipt not provided'
|
||||
|
||||
def test_mark_as_reimbursed(self, app, test_user, test_admin):
|
||||
"""Test marking expense as reimbursed."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
# Approve first
|
||||
expense.approve(test_admin)
|
||||
db.session.commit()
|
||||
|
||||
# Mark as reimbursed
|
||||
expense.mark_as_reimbursed()
|
||||
db.session.commit()
|
||||
|
||||
assert expense.reimbursed is True
|
||||
assert expense.reimbursed_at is not None
|
||||
assert expense.status == 'reimbursed'
|
||||
|
||||
def test_mark_as_invoiced(self, app, test_user, test_invoice):
|
||||
"""Test marking expense as invoiced."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today(),
|
||||
billable=True
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
expense.mark_as_invoiced(test_invoice)
|
||||
db.session.commit()
|
||||
|
||||
assert expense.invoiced is True
|
||||
assert expense.invoice_id == test_invoice
|
||||
|
||||
def test_to_dict(self, app, test_user):
|
||||
"""Test converting expense to dictionary."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
tax_amount=Decimal('10.00'),
|
||||
expense_date=date.today(),
|
||||
description='Test description'
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
expense = db.session.get(Expense, expense.id)
|
||||
expense_dict = expense.to_dict()
|
||||
|
||||
assert expense_dict['id'] == expense.id
|
||||
assert expense_dict['user_id'] == test_user
|
||||
assert expense_dict['title'] == 'Test Expense'
|
||||
assert expense_dict['category'] == 'travel'
|
||||
assert expense_dict['amount'] == 100.00
|
||||
assert expense_dict['tax_amount'] == 10.00
|
||||
assert expense_dict['total_amount'] == 110.00
|
||||
assert 'created_at' in expense_dict
|
||||
|
||||
|
||||
class TestExpenseQueries:
|
||||
"""Test Expense query class methods."""
|
||||
|
||||
def test_get_expenses(self, app, test_user):
|
||||
"""Test retrieving expenses."""
|
||||
with app.app_context():
|
||||
expenses = [
|
||||
Expense(
|
||||
user_id=test_user,
|
||||
title=f'Expense {i}',
|
||||
category='travel',
|
||||
amount=Decimal(f'{100 + i * 10}.00'),
|
||||
expense_date=date.today() - timedelta(days=i)
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
db.session.add_all(expenses)
|
||||
db.session.commit()
|
||||
|
||||
retrieved = Expense.get_expenses(user_id=test_user)
|
||||
assert len(retrieved) == 5
|
||||
|
||||
# Should be ordered by expense_date desc
|
||||
assert retrieved[0].title == 'Expense 0'
|
||||
|
||||
def test_get_expenses_by_status(self, app, test_user, test_admin):
|
||||
"""Test filtering expenses by status."""
|
||||
with app.app_context():
|
||||
# Create expenses with different statuses
|
||||
exp1 = Expense(
|
||||
user_id=test_user,
|
||||
title='Pending Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
exp2 = Expense(
|
||||
user_id=test_user,
|
||||
title='Approved Expense',
|
||||
category='travel',
|
||||
amount=Decimal('200.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add_all([exp1, exp2])
|
||||
db.session.commit()
|
||||
|
||||
exp2.approve(test_admin)
|
||||
db.session.commit()
|
||||
|
||||
pending = Expense.get_expenses(user_id=test_user, status='pending')
|
||||
assert len(pending) == 1
|
||||
assert pending[0].title == 'Pending Expense'
|
||||
|
||||
approved = Expense.get_expenses(user_id=test_user, status='approved')
|
||||
assert len(approved) == 1
|
||||
assert approved[0].title == 'Approved Expense'
|
||||
|
||||
def test_get_total_expenses(self, app, test_user):
|
||||
"""Test calculating total expenses."""
|
||||
with app.app_context():
|
||||
amounts = [Decimal('100.00'), Decimal('250.50'), Decimal('75.25')]
|
||||
taxes = [Decimal('10.00'), Decimal('25.00'), Decimal('7.50')]
|
||||
|
||||
expenses = [
|
||||
Expense(
|
||||
user_id=test_user,
|
||||
title=f'Expense {i}',
|
||||
category='travel',
|
||||
amount=amount,
|
||||
tax_amount=tax,
|
||||
expense_date=date.today()
|
||||
)
|
||||
for i, (amount, tax) in enumerate(zip(amounts, taxes))
|
||||
]
|
||||
db.session.add_all(expenses)
|
||||
db.session.commit()
|
||||
|
||||
total = Expense.get_total_expenses(user_id=test_user, include_tax=True)
|
||||
expected = sum(amounts) + sum(taxes)
|
||||
assert abs(total - float(expected)) < 0.01
|
||||
|
||||
def test_get_expenses_by_category(self, app, test_user):
|
||||
"""Test grouping expenses by category."""
|
||||
with app.app_context():
|
||||
categories = ['travel', 'travel', 'meals', 'supplies', 'meals']
|
||||
amounts = [Decimal('100.00'), Decimal('150.00'), Decimal('50.00'),
|
||||
Decimal('75.00'), Decimal('60.00')]
|
||||
|
||||
expenses = [
|
||||
Expense(
|
||||
user_id=test_user,
|
||||
title=f'Expense {i}',
|
||||
category=category,
|
||||
amount=amount,
|
||||
expense_date=date.today()
|
||||
)
|
||||
for i, (category, amount) in enumerate(zip(categories, amounts))
|
||||
]
|
||||
db.session.add_all(expenses)
|
||||
db.session.commit()
|
||||
|
||||
by_category = Expense.get_expenses_by_category(user_id=test_user)
|
||||
|
||||
assert len(by_category) == 3
|
||||
|
||||
travel = next(c for c in by_category if c['category'] == 'travel')
|
||||
assert travel['count'] == 2
|
||||
assert abs(travel['total_amount'] - 250.00) < 0.01
|
||||
|
||||
def test_get_pending_approvals(self, app, test_user):
|
||||
"""Test retrieving pending expenses."""
|
||||
with app.app_context():
|
||||
exp1 = Expense(
|
||||
user_id=test_user,
|
||||
title='Pending 1',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today(),
|
||||
status='pending'
|
||||
)
|
||||
exp2 = Expense(
|
||||
user_id=test_user,
|
||||
title='Pending 2',
|
||||
category='travel',
|
||||
amount=Decimal('200.00'),
|
||||
expense_date=date.today(),
|
||||
status='pending'
|
||||
)
|
||||
db.session.add_all([exp1, exp2])
|
||||
db.session.commit()
|
||||
|
||||
pending = Expense.get_pending_approvals(user_id=test_user)
|
||||
assert len(pending) == 2
|
||||
|
||||
def test_get_uninvoiced_expenses(self, app, test_user, test_admin, test_project):
|
||||
"""Test retrieving uninvoiced billable expenses."""
|
||||
with app.app_context():
|
||||
exp1 = Expense(
|
||||
user_id=test_user,
|
||||
title='Billable Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today(),
|
||||
billable=True,
|
||||
project_id=test_project
|
||||
)
|
||||
exp2 = Expense(
|
||||
user_id=test_user,
|
||||
title='Non-billable Expense',
|
||||
category='travel',
|
||||
amount=Decimal('200.00'),
|
||||
expense_date=date.today(),
|
||||
billable=False,
|
||||
project_id=test_project
|
||||
)
|
||||
db.session.add_all([exp1, exp2])
|
||||
db.session.commit()
|
||||
|
||||
# Approve both
|
||||
exp1.approve(test_admin)
|
||||
exp2.approve(test_admin)
|
||||
db.session.commit()
|
||||
|
||||
uninvoiced = Expense.get_uninvoiced_expenses(project_id=test_project)
|
||||
assert len(uninvoiced) == 1
|
||||
assert uninvoiced[0].title == 'Billable Expense'
|
||||
|
||||
|
||||
class TestExpenseConstraints:
|
||||
"""Test database constraints and data integrity."""
|
||||
|
||||
def test_cannot_create_expense_without_user(self, app):
|
||||
"""Test that user_id is required."""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=None,
|
||||
title='Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('100.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
db.session.commit()
|
||||
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
# Smoke Tests
|
||||
|
||||
class TestExpenseSmokeTests:
|
||||
"""Basic smoke tests to ensure Expense functionality works."""
|
||||
|
||||
def test_expense_creation_smoke(self, app, test_user):
|
||||
"""Smoke test: Can we create an expense?"""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Smoke Test Expense',
|
||||
category='travel',
|
||||
amount=Decimal('99.99'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
assert expense.id is not None
|
||||
|
||||
def test_expense_query_smoke(self, app, test_user):
|
||||
"""Smoke test: Can we query expenses?"""
|
||||
with app.app_context():
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Query Smoke Test',
|
||||
category='travel',
|
||||
amount=Decimal('200.00'),
|
||||
expense_date=date.today()
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
expenses = Expense.query.filter_by(user_id=test_user).all()
|
||||
assert len(expenses) > 0
|
||||
|
||||
def test_expense_workflow_smoke(self, app, test_user, test_admin):
|
||||
"""Smoke test: Does the full approval workflow work?"""
|
||||
with app.app_context():
|
||||
# Create expense
|
||||
expense = Expense(
|
||||
user_id=test_user,
|
||||
title='Workflow Test',
|
||||
category='travel',
|
||||
amount=Decimal('500.00'),
|
||||
expense_date=date.today(),
|
||||
reimbursable=True
|
||||
)
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
# Approve
|
||||
expense.approve(test_admin)
|
||||
db.session.commit()
|
||||
assert expense.status == 'approved'
|
||||
|
||||
# Reimburse
|
||||
expense.mark_as_reimbursed()
|
||||
db.session.commit()
|
||||
assert expense.status == 'reimbursed'
|
||||
|
||||
Reference in New Issue
Block a user