mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-21 20:09:57 -06:00
Implement a complete expense tracking feature that allows users to record, manage, approve, and track business expenses with full integration into existing project management and invoicing systems. Features: - Create and manage expenses with detailed information (amount, category, vendor, receipts, tax tracking) - Multi-currency support (EUR, USD, GBP, CHF) - Approval workflow with admin oversight (pending → approved → rejected) - Reimbursement tracking and status management - Billable expense flagging for client invoicing - Receipt file upload and attachment - Project and client association with auto-client selection - Tag-based organization and advanced filtering - CSV export functionality - Analytics dashboard with category breakdowns - API endpoints for programmatic access Database Changes: - Add expenses table with comprehensive schema - Create Alembic migration (029_add_expenses_table.py) - Add composite indexes for query performance - Implement proper foreign key constraints and cascading Routes & Templates: - Add expenses blueprint with 14 endpoints (CRUD, approval, export, API) - Create 4 responsive templates (list, form, view, dashboard) - Implement advanced filtering (status, category, project, client, date range) - Add permission-based access control (user vs admin) - Integrate receipt file upload handling User Experience: - Add "Expenses" to Insights navigation menu - Auto-populate client when project is selected - Provide visual feedback for auto-selections - Display summary statistics and analytics - Implement pagination and search functionality Testing & Documentation: - Add 40+ comprehensive tests covering models, methods, and workflows - Create complete user documentation (docs/EXPENSE_TRACKING.md) - Add API documentation and examples - Include troubleshooting guide and best practices Integration: - Link expenses to projects for cost tracking - Associate with clients for billing purposes - Connect billable expenses to invoicing system - Add PostHog event tracking for analytics - Implement structured logging for audit trail Security: - Role-based access control (users see only their expenses) - Admin-only approval and reimbursement actions - CSRF protection and file upload validation - Proper permission checks on all operations This implementation follows existing codebase patterns and includes full test coverage, documentation, and database migrations per project standards.
381 lines
14 KiB
Python
381 lines
14 KiB
Python
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'
|
|
]
|
|
|