From b353184a4f1c4bcbb4f5d27497457dcdf23c4f76 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 31 Oct 2025 06:21:35 +0100 Subject: [PATCH] feat: implement advanced expense management with templates and navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement complete Advanced Expense Management feature set with UI templates, database schema fixes, and reorganized navigation structure. Features: - Expense Categories: Full CRUD with budget tracking and visualization - Mileage Tracking: Vehicle mileage entries with approval workflow - Per Diem Management: Daily allowance claims with location-based rates - Receipt OCR: Infrastructure for receipt scanning (utilities ready) Database: - Migration 037: Create expense_categories, mileage, per_diem_rates, per_diems tables - Migration 038: Fix schema column name mismatches (trip_purpose→purpose, etc.) - Add missing columns (description, odometer, rates, reimbursement tracking) - Fix circular foreign key dependencies Templates (11 new files): - expense_categories/: list, form, view - mileage/: list, form, view - per_diem/: list, form, view, rates_list, rate_form Navigation: - Move Mileage and Per Diem to Expenses sub-pages (header buttons) - Move Expense Categories to Admin menu only - Remove expense management items from Finance menu Fixes: - Fix NoneType comparison error in expense categories utilization - Handle None values safely in budget progress bars - Resolve database column name mismatches UI/UX: - Responsive design with Tailwind CSS and dark mode support - Real-time calculations for mileage amounts - Color-coded budget utilization progress bars - Status badges for approval workflow states - Advanced filtering on all list views Default data: - 7 expense categories (Travel, Meals, Accommodation, etc.) - 4 per diem rates (US, GB, DE, FR) --- app/__init__.py | 6 + app/models/__init__.py | 3 + app/models/expense_category.py | 144 +++++ app/models/mileage.py | 249 ++++++++ app/models/per_diem.py | 418 ++++++++++++++ app/routes/expense_categories.py | 253 ++++++++ app/routes/expenses.py | 263 +++++++++ app/routes/mileage.py | 466 +++++++++++++++ app/routes/per_diem.py | 542 ++++++++++++++++++ app/templates/base.html | 8 +- app/templates/expense_categories/form.html | 196 +++++++ app/templates/expense_categories/list.html | 160 ++++++ app/templates/expense_categories/view.html | 208 +++++++ app/templates/expenses/list.html | 7 +- app/templates/mileage/form.html | 309 ++++++++++ app/templates/mileage/list.html | 214 +++++++ app/templates/mileage/view.html | 302 ++++++++++ app/templates/per_diem/form.html | 230 ++++++++ app/templates/per_diem/list.html | 182 ++++++ app/templates/per_diem/rate_form.html | 175 ++++++ app/templates/per_diem/rates_list.html | 84 +++ app/templates/per_diem/view.html | 115 ++++ app/utils/ocr.py | 344 +++++++++++ fix_expense_schema.sql | 77 +++ migrations/versions/037_advanced_expenses.py | 204 +++++++ .../038_fix_advanced_expenses_schema.py | 147 +++++ requirements.txt | 5 +- temp_migration.sql | 3 + tests/test_models/test_expense_category.py | 219 +++++++ tests/test_models/test_mileage.py | 276 +++++++++ tests/test_models/test_per_diem.py | 338 +++++++++++ 31 files changed, 6144 insertions(+), 3 deletions(-) create mode 100644 app/models/expense_category.py create mode 100644 app/models/mileage.py create mode 100644 app/models/per_diem.py create mode 100644 app/routes/expense_categories.py create mode 100644 app/routes/mileage.py create mode 100644 app/routes/per_diem.py create mode 100644 app/templates/expense_categories/form.html create mode 100644 app/templates/expense_categories/list.html create mode 100644 app/templates/expense_categories/view.html create mode 100644 app/templates/mileage/form.html create mode 100644 app/templates/mileage/list.html create mode 100644 app/templates/mileage/view.html create mode 100644 app/templates/per_diem/form.html create mode 100644 app/templates/per_diem/list.html create mode 100644 app/templates/per_diem/rate_form.html create mode 100644 app/templates/per_diem/rates_list.html create mode 100644 app/templates/per_diem/view.html create mode 100644 app/utils/ocr.py create mode 100644 fix_expense_schema.sql create mode 100644 migrations/versions/037_advanced_expenses.py create mode 100644 migrations/versions/038_fix_advanced_expenses_schema.py create mode 100644 temp_migration.sql create mode 100644 tests/test_models/test_expense_category.py create mode 100644 tests/test_models/test_mileage.py create mode 100644 tests/test_models/test_per_diem.py diff --git a/app/__init__.py b/app/__init__.py index 3129183..5e6c03c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -770,6 +770,9 @@ def create_app(config=None): from app.routes.expenses import expenses_bp from app.routes.permissions import permissions_bp from app.routes.calendar import calendar_bp + from app.routes.expense_categories import expense_categories_bp + from app.routes.mileage import mileage_bp + from app.routes.per_diem import per_diem_bp app.register_blueprint(auth_bp) app.register_blueprint(main_bp) @@ -798,6 +801,9 @@ def create_app(config=None): app.register_blueprint(expenses_bp) app.register_blueprint(permissions_bp) app.register_blueprint(calendar_bp) + app.register_blueprint(expense_categories_bp) + app.register_blueprint(mileage_bp) + app.register_blueprint(per_diem_bp) # Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens) # Only if CSRF is enabled diff --git a/app/models/__init__.py b/app/models/__init__.py index 2ab7760..e0118fb 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -11,6 +11,9 @@ from .payments import Payment, CreditNote, InvoiceReminderSchedule from .reporting import SavedReportView, ReportEmailSchedule from .client import Client from .task_activity import TaskActivity +from .expense_category import ExpenseCategory +from .mileage import Mileage +from .per_diem import PerDiem, PerDiemRate from .extra_good import ExtraGood from .comment import Comment from .focus_session import FocusSession diff --git a/app/models/expense_category.py b/app/models/expense_category.py new file mode 100644 index 0000000..e6c0ff4 --- /dev/null +++ b/app/models/expense_category.py @@ -0,0 +1,144 @@ +from datetime import datetime +from decimal import Decimal +from app import db +from sqlalchemy import Index + + +class ExpenseCategory(db.Model): + """Expense category model with budget tracking""" + + __tablename__ = 'expense_categories' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True, index=True) + description = db.Column(db.Text, nullable=True) + code = db.Column(db.String(20), nullable=True, unique=True, index=True) # Short code for quick reference + color = db.Column(db.String(7), nullable=True) # Hex color for UI (e.g., #FF5733) + icon = db.Column(db.String(50), nullable=True) # Icon name for UI + + # Budget settings + monthly_budget = db.Column(db.Numeric(10, 2), nullable=True) + quarterly_budget = db.Column(db.Numeric(10, 2), nullable=True) + yearly_budget = db.Column(db.Numeric(10, 2), nullable=True) + budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # Alert when exceeded + + # Settings + requires_receipt = db.Column(db.Boolean, default=True, nullable=False) + requires_approval = db.Column(db.Boolean, default=True, nullable=False) + default_tax_rate = db.Column(db.Numeric(5, 2), nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + + # Metadata + 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) + + def __init__(self, name, **kwargs): + self.name = name.strip() + self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None + self.code = kwargs.get('code', '').strip() if kwargs.get('code') else None + self.color = kwargs.get('color') + self.icon = kwargs.get('icon') + self.monthly_budget = Decimal(str(kwargs.get('monthly_budget'))) if kwargs.get('monthly_budget') else None + self.quarterly_budget = Decimal(str(kwargs.get('quarterly_budget'))) if kwargs.get('quarterly_budget') else None + self.yearly_budget = Decimal(str(kwargs.get('yearly_budget'))) if kwargs.get('yearly_budget') else None + self.budget_threshold_percent = kwargs.get('budget_threshold_percent', 80) + self.requires_receipt = kwargs.get('requires_receipt', True) + self.requires_approval = kwargs.get('requires_approval', True) + self.default_tax_rate = Decimal(str(kwargs.get('default_tax_rate'))) if kwargs.get('default_tax_rate') else None + self.is_active = kwargs.get('is_active', True) + + def __repr__(self): + return f'' + + def get_spent_amount(self, start_date, end_date): + """Get total amount spent in this category for date range""" + from app.models.expense import Expense + + query = db.session.query( + db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0)) + ).filter( + Expense.category == self.name, + Expense.status.in_(['approved', 'reimbursed']), + Expense.expense_date >= start_date, + Expense.expense_date <= end_date + ) + + total = query.scalar() or Decimal('0') + return float(total) + + def get_budget_utilization(self, period='monthly'): + """Get budget utilization percentage for the current period""" + from datetime import date + + today = date.today() + + if period == 'monthly': + start_date = date(today.year, today.month, 1) + budget = self.monthly_budget + elif period == 'quarterly': + quarter = (today.month - 1) // 3 + 1 + start_month = (quarter - 1) * 3 + 1 + start_date = date(today.year, start_month, 1) + budget = self.quarterly_budget + elif period == 'yearly': + start_date = date(today.year, 1, 1) + budget = self.yearly_budget + else: + return None + + if not budget or budget == 0: + return None + + spent = self.get_spent_amount(start_date, today) + utilization = (spent / float(budget)) * 100 + + return { + 'spent': spent, + 'budget': float(budget), + 'utilization_percent': round(utilization, 2), + 'remaining': float(budget) - spent, + 'over_threshold': utilization >= self.budget_threshold_percent + } + + def to_dict(self): + """Convert category to dictionary for API responses""" + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'code': self.code, + 'color': self.color, + 'icon': self.icon, + 'monthly_budget': float(self.monthly_budget) if self.monthly_budget else None, + 'quarterly_budget': float(self.quarterly_budget) if self.quarterly_budget else None, + 'yearly_budget': float(self.yearly_budget) if self.yearly_budget else None, + 'budget_threshold_percent': self.budget_threshold_percent, + 'requires_receipt': self.requires_receipt, + 'requires_approval': self.requires_approval, + 'default_tax_rate': float(self.default_tax_rate) if self.default_tax_rate else None, + 'is_active': self.is_active, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + @classmethod + def get_active_categories(cls): + """Get all active categories""" + return cls.query.filter_by(is_active=True).order_by(cls.name).all() + + @classmethod + def get_categories_over_budget(cls, period='monthly'): + """Get categories that are over their budget threshold""" + categories = cls.get_active_categories() + over_budget = [] + + for category in categories: + utilization = category.get_budget_utilization(period) + if utilization and utilization['over_threshold']: + over_budget.append({ + 'category': category, + 'utilization': utilization + }) + + return over_budget + diff --git a/app/models/mileage.py b/app/models/mileage.py new file mode 100644 index 0000000..8b1524c --- /dev/null +++ b/app/models/mileage.py @@ -0,0 +1,249 @@ +from datetime import datetime +from decimal import Decimal +from app import db +from sqlalchemy import Index + + +class Mileage(db.Model): + """Mileage tracking for business travel""" + + __tablename__ = 'mileage' + + 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_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True) + + # Trip details + trip_date = db.Column(db.Date, nullable=False, index=True) + purpose = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + + # Location information + start_location = db.Column(db.String(200), nullable=False) + end_location = db.Column(db.String(200), nullable=False) + start_odometer = db.Column(db.Numeric(10, 2), nullable=True) # Optional odometer readings + end_odometer = db.Column(db.Numeric(10, 2), nullable=True) + + # Distance and calculation + distance_km = db.Column(db.Numeric(10, 2), nullable=False) + distance_miles = db.Column(db.Numeric(10, 2), nullable=True) # Computed or manual + rate_per_km = db.Column(db.Numeric(10, 4), nullable=False) # Rate at time of entry + rate_per_mile = db.Column(db.Numeric(10, 4), nullable=True) + currency_code = db.Column(db.String(3), nullable=False, default='EUR') + + # Vehicle information + vehicle_type = db.Column(db.String(50), nullable=True) # 'car', 'motorcycle', 'van', 'truck' + vehicle_description = db.Column(db.String(200), nullable=True) # e.g., "BMW 3 Series" + license_plate = db.Column(db.String(20), nullable=True) + + # Calculated amount + calculated_amount = db.Column(db.Numeric(10, 2), nullable=False) + + # Round trip + is_round_trip = db.Column(db.Boolean, default=False, nullable=False) + + # 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) + + # Reimbursement + reimbursed = db.Column(db.Boolean, default=False, nullable=False) + reimbursed_at = db.Column(db.DateTime, nullable=True) + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Metadata + 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('mileage_entries', lazy='dynamic')) + approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_mileage', lazy='dynamic')) + project = db.relationship('Project', backref=db.backref('mileage_entries', lazy='dynamic')) + client = db.relationship('Client', backref=db.backref('mileage_entries', lazy='dynamic')) + expense = db.relationship('Expense', backref=db.backref('mileage_entry', uselist=False)) + + # Indexes for common queries + __table_args__ = ( + Index('ix_mileage_user_date', 'user_id', 'trip_date'), + Index('ix_mileage_status_date', 'status', 'trip_date'), + ) + + def __init__(self, user_id, trip_date, purpose, start_location, end_location, + distance_km, rate_per_km, **kwargs): + self.user_id = user_id + self.trip_date = trip_date + self.purpose = purpose.strip() + self.start_location = start_location.strip() + self.end_location = end_location.strip() + self.distance_km = Decimal(str(distance_km)) + self.rate_per_km = Decimal(str(rate_per_km)) + + # Calculate amount + self.calculated_amount = self.distance_km * self.rate_per_km + + # 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.expense_id = kwargs.get('expense_id') + self.start_odometer = Decimal(str(kwargs.get('start_odometer'))) if kwargs.get('start_odometer') else None + self.end_odometer = Decimal(str(kwargs.get('end_odometer'))) if kwargs.get('end_odometer') else None + self.distance_miles = Decimal(str(kwargs.get('distance_miles'))) if kwargs.get('distance_miles') else self.distance_km * Decimal('0.621371') + self.rate_per_mile = Decimal(str(kwargs.get('rate_per_mile'))) if kwargs.get('rate_per_mile') else None + self.currency_code = kwargs.get('currency_code', 'EUR') + self.vehicle_type = kwargs.get('vehicle_type') + self.vehicle_description = kwargs.get('vehicle_description') + self.license_plate = kwargs.get('license_plate') + self.is_round_trip = kwargs.get('is_round_trip', False) + self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None + self.status = kwargs.get('status', 'pending') + + def __repr__(self): + return f' {self.end_location} ({self.distance_km} km)>' + + @property + def total_distance_km(self): + """Get total distance including round trip if applicable""" + multiplier = 2 if self.is_round_trip else 1 + return float(self.distance_km) * multiplier + + @property + def total_amount(self): + """Get total amount including round trip if applicable""" + multiplier = 2 if self.is_round_trip else 1 + return float(self.calculated_amount) * multiplier + + def approve(self, approved_by_user_id, notes=None): + """Approve the mileage entry""" + 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 mileage entry""" + 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 mileage entry as reimbursed""" + self.reimbursed = True + self.reimbursed_at = datetime.utcnow() + self.status = 'reimbursed' + self.updated_at = datetime.utcnow() + + def create_expense(self): + """Create an expense from this mileage entry""" + from app.models.expense import Expense + + if self.expense_id: + return None # Already has an expense + + expense = Expense( + user_id=self.user_id, + title=f"Mileage: {self.start_location} to {self.end_location}", + category='travel', + amount=self.total_amount, + expense_date=self.trip_date, + description=f"{self.purpose}\nDistance: {self.total_distance_km} km @ {float(self.rate_per_km)} {self.currency_code}/km", + project_id=self.project_id, + client_id=self.client_id, + currency_code=self.currency_code, + status=self.status + ) + + return expense + + def to_dict(self): + """Convert mileage entry to dictionary for API responses""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'project_id': self.project_id, + 'client_id': self.client_id, + 'expense_id': self.expense_id, + 'trip_date': self.trip_date.isoformat() if self.trip_date else None, + 'purpose': self.purpose, + 'description': self.description, + 'start_location': self.start_location, + 'end_location': self.end_location, + 'start_odometer': float(self.start_odometer) if self.start_odometer else None, + 'end_odometer': float(self.end_odometer) if self.end_odometer else None, + 'distance_km': float(self.distance_km), + 'distance_miles': float(self.distance_miles) if self.distance_miles else None, + 'rate_per_km': float(self.rate_per_km), + 'rate_per_mile': float(self.rate_per_mile) if self.rate_per_mile else None, + 'currency_code': self.currency_code, + 'vehicle_type': self.vehicle_type, + 'vehicle_description': self.vehicle_description, + 'license_plate': self.license_plate, + 'calculated_amount': float(self.calculated_amount), + 'is_round_trip': self.is_round_trip, + 'total_distance_km': self.total_distance_km, + 'total_amount': self.total_amount, + '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, + 'reimbursed': self.reimbursed, + 'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None, + 'notes': self.notes, + '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_default_rates(cls): + """Get default mileage rates for different vehicle types""" + # These are example rates and should be configurable in settings + return { + 'car': {'km': 0.30, 'mile': 0.48, 'currency': 'EUR'}, + 'motorcycle': {'km': 0.20, 'mile': 0.32, 'currency': 'EUR'}, + 'van': {'km': 0.35, 'mile': 0.56, 'currency': 'EUR'}, + 'truck': {'km': 0.40, 'mile': 0.64, 'currency': 'EUR'} + } + + @classmethod + def get_pending_approvals(cls, user_id=None): + """Get mileage entries 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.trip_date.desc()).all() + + @classmethod + def get_total_distance(cls, user_id=None, start_date=None, end_date=None): + """Calculate total distance traveled""" + query = db.session.query(db.func.sum(cls.distance_km)) + + if user_id: + query = query.filter(cls.user_id == user_id) + + if start_date: + query = query.filter(cls.trip_date >= start_date) + + if end_date: + query = query.filter(cls.trip_date <= end_date) + + query = query.filter(cls.status.in_(['approved', 'reimbursed'])) + + total = query.scalar() or Decimal('0') + return float(total) + diff --git a/app/models/per_diem.py b/app/models/per_diem.py new file mode 100644 index 0000000..aa20a69 --- /dev/null +++ b/app/models/per_diem.py @@ -0,0 +1,418 @@ +from datetime import datetime, timedelta +from decimal import Decimal +from app import db +from sqlalchemy import Index + + +class PerDiemRate(db.Model): + """Per diem rate configuration for different locations""" + + __tablename__ = 'per_diem_rates' + + id = db.Column(db.Integer, primary_key=True) + country = db.Column(db.String(100), nullable=False, index=True) + city = db.Column(db.String(100), nullable=True, index=True) + + # Rates + full_day_rate = db.Column(db.Numeric(10, 2), nullable=False) + half_day_rate = db.Column(db.Numeric(10, 2), nullable=False) + breakfast_rate = db.Column(db.Numeric(10, 2), nullable=True) + lunch_rate = db.Column(db.Numeric(10, 2), nullable=True) + dinner_rate = db.Column(db.Numeric(10, 2), nullable=True) + incidental_rate = db.Column(db.Numeric(10, 2), nullable=True) # Tips, etc. + + currency_code = db.Column(db.String(3), nullable=False, default='EUR') + + # Validity period + effective_from = db.Column(db.Date, nullable=False, index=True) + effective_to = db.Column(db.Date, nullable=True, index=True) + + # Settings + is_active = db.Column(db.Boolean, default=True, nullable=False) + notes = db.Column(db.Text, nullable=True) + + # Metadata + 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) + + __table_args__ = ( + Index('ix_per_diem_rates_country_city', 'country', 'city'), + Index('ix_per_diem_rates_effective', 'effective_from', 'effective_to'), + ) + + def __init__(self, country, full_day_rate, half_day_rate, effective_from, **kwargs): + self.country = country.strip() + self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None + self.full_day_rate = Decimal(str(full_day_rate)) + self.half_day_rate = Decimal(str(half_day_rate)) + self.breakfast_rate = Decimal(str(kwargs.get('breakfast_rate'))) if kwargs.get('breakfast_rate') else None + self.lunch_rate = Decimal(str(kwargs.get('lunch_rate'))) if kwargs.get('lunch_rate') else None + self.dinner_rate = Decimal(str(kwargs.get('dinner_rate'))) if kwargs.get('dinner_rate') else None + self.incidental_rate = Decimal(str(kwargs.get('incidental_rate'))) if kwargs.get('incidental_rate') else None + self.currency_code = kwargs.get('currency_code', 'EUR') + self.effective_from = effective_from + self.effective_to = kwargs.get('effective_to') + self.is_active = kwargs.get('is_active', True) + self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None + + def __repr__(self): + location = f"{self.city}, {self.country}" if self.city else self.country + return f'' + + def to_dict(self): + """Convert rate to dictionary for API responses""" + return { + 'id': self.id, + 'country': self.country, + 'city': self.city, + 'full_day_rate': float(self.full_day_rate), + 'half_day_rate': float(self.half_day_rate), + 'breakfast_rate': float(self.breakfast_rate) if self.breakfast_rate else None, + 'lunch_rate': float(self.lunch_rate) if self.lunch_rate else None, + 'dinner_rate': float(self.dinner_rate) if self.dinner_rate else None, + 'incidental_rate': float(self.incidental_rate) if self.incidental_rate else None, + 'currency_code': self.currency_code, + 'effective_from': self.effective_from.isoformat() if self.effective_from else None, + 'effective_to': self.effective_to.isoformat() if self.effective_to else None, + 'is_active': self.is_active, + 'notes': self.notes, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + @classmethod + def get_rate_for_location(cls, country, city=None, date=None): + """Get applicable per diem rate for a location and date""" + from datetime import date as dt_date + + if date is None: + date = dt_date.today() + + query = cls.query.filter( + cls.country == country, + cls.is_active == True, + cls.effective_from <= date + ) + + if city: + # Try to find city-specific rate first + city_rate = query.filter(cls.city == city).filter( + db.or_(cls.effective_to.is_(None), cls.effective_to >= date) + ).first() + + if city_rate: + return city_rate + + # Fall back to country rate + country_rate = query.filter(cls.city.is_(None)).filter( + db.or_(cls.effective_to.is_(None), cls.effective_to >= date) + ).first() + + return country_rate + + +class PerDiem(db.Model): + """Per diem claim for business travel""" + + __tablename__ = 'per_diems' + + 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_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True) + per_diem_rate_id = db.Column(db.Integer, db.ForeignKey('per_diem_rates.id'), nullable=True, index=True) + + # Trip details + trip_purpose = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + + # Date range + start_date = db.Column(db.Date, nullable=False, index=True) + end_date = db.Column(db.Date, nullable=False, index=True) + departure_time = db.Column(db.Time, nullable=True) + return_time = db.Column(db.Time, nullable=True) + + # Location + country = db.Column(db.String(100), nullable=False) + city = db.Column(db.String(100), nullable=True) + + # Calculation details + full_days = db.Column(db.Integer, default=0, nullable=False) + half_days = db.Column(db.Integer, default=0, nullable=False) + + # Meal deductions (if meals were provided) + breakfast_provided = db.Column(db.Integer, default=0, nullable=False) # Number of breakfasts + lunch_provided = db.Column(db.Integer, default=0, nullable=False) + dinner_provided = db.Column(db.Integer, default=0, nullable=False) + + # Rates used (stored at time of creation) + full_day_rate = db.Column(db.Numeric(10, 2), nullable=False) + half_day_rate = db.Column(db.Numeric(10, 2), nullable=False) + breakfast_deduction = db.Column(db.Numeric(10, 2), nullable=True) + lunch_deduction = db.Column(db.Numeric(10, 2), nullable=True) + dinner_deduction = db.Column(db.Numeric(10, 2), nullable=True) + + # Calculated amount + calculated_amount = db.Column(db.Numeric(10, 2), nullable=False) + currency_code = db.Column(db.String(3), nullable=False, default='EUR') + + # 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) + + # Reimbursement + reimbursed = db.Column(db.Boolean, default=False, nullable=False) + reimbursed_at = db.Column(db.DateTime, nullable=True) + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Metadata + 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('per_diem_claims', lazy='dynamic')) + approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_per_diems', lazy='dynamic')) + project = db.relationship('Project', backref=db.backref('per_diem_claims', lazy='dynamic')) + client = db.relationship('Client', backref=db.backref('per_diem_claims', lazy='dynamic')) + expense = db.relationship('Expense', backref=db.backref('per_diem_claim', uselist=False)) + rate = db.relationship('PerDiemRate', backref=db.backref('per_diem_claims', lazy='dynamic')) + + # Indexes for common queries + __table_args__ = ( + Index('ix_per_diems_user_date', 'user_id', 'start_date'), + Index('ix_per_diems_status_date', 'status', 'start_date'), + ) + + def __init__(self, user_id, trip_purpose, start_date, end_date, country, + full_day_rate, half_day_rate, **kwargs): + self.user_id = user_id + self.trip_purpose = trip_purpose.strip() + self.start_date = start_date + self.end_date = end_date + self.country = country.strip() + self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None + + # Store rates + self.full_day_rate = Decimal(str(full_day_rate)) + self.half_day_rate = Decimal(str(half_day_rate)) + + # 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.expense_id = kwargs.get('expense_id') + self.per_diem_rate_id = kwargs.get('per_diem_rate_id') + self.departure_time = kwargs.get('departure_time') + self.return_time = kwargs.get('return_time') + self.full_days = kwargs.get('full_days', 0) + self.half_days = kwargs.get('half_days', 0) + self.breakfast_provided = kwargs.get('breakfast_provided', 0) + self.lunch_provided = kwargs.get('lunch_provided', 0) + self.dinner_provided = kwargs.get('dinner_provided', 0) + self.breakfast_deduction = Decimal(str(kwargs.get('breakfast_deduction', 0))) + self.lunch_deduction = Decimal(str(kwargs.get('lunch_deduction', 0))) + self.dinner_deduction = Decimal(str(kwargs.get('dinner_deduction', 0))) + self.currency_code = kwargs.get('currency_code', 'EUR') + self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None + self.status = kwargs.get('status', 'pending') + + # Calculate amount + self.calculated_amount = self._calculate_amount() + + def _calculate_amount(self): + """Calculate the per diem amount based on days and deductions""" + # Base amount + amount = (self.full_day_rate * self.full_days) + (self.half_day_rate * self.half_days) + + # Deduct provided meals + amount -= (self.breakfast_deduction * self.breakfast_provided) + amount -= (self.lunch_deduction * self.lunch_provided) + amount -= (self.dinner_deduction * self.dinner_provided) + + return max(Decimal('0'), amount) # Ensure non-negative + + def recalculate_amount(self): + """Recalculate the amount (useful when days or deductions change)""" + self.calculated_amount = self._calculate_amount() + return self.calculated_amount + + def __repr__(self): + location = f"{self.city}, {self.country}" if self.city else self.country + return f'' + + @property + def total_days(self): + """Get total number of days (full + half)""" + return self.full_days + (self.half_days * 0.5) + + @property + def trip_duration(self): + """Get trip duration in days""" + return (self.end_date - self.start_date).days + 1 + + def approve(self, approved_by_user_id, notes=None): + """Approve the per diem claim""" + 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 per diem claim""" + 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 per diem claim as reimbursed""" + self.reimbursed = True + self.reimbursed_at = datetime.utcnow() + self.status = 'reimbursed' + self.updated_at = datetime.utcnow() + + def create_expense(self): + """Create an expense from this per diem claim""" + from app.models.expense import Expense + + if self.expense_id: + return None # Already has an expense + + location = f"{self.city}, {self.country}" if self.city else self.country + + expense = Expense( + user_id=self.user_id, + title=f"Per Diem: {location}", + category='meals', + amount=self.calculated_amount, + expense_date=self.start_date, + description=f"{self.trip_purpose}\n{self.start_date} to {self.end_date} ({self.total_days} days)", + project_id=self.project_id, + client_id=self.client_id, + currency_code=self.currency_code, + status=self.status + ) + + return expense + + def to_dict(self): + """Convert per diem claim to dictionary for API responses""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'project_id': self.project_id, + 'client_id': self.client_id, + 'expense_id': self.expense_id, + 'per_diem_rate_id': self.per_diem_rate_id, + 'trip_purpose': self.trip_purpose, + 'description': self.description, + 'start_date': self.start_date.isoformat() if self.start_date else None, + 'end_date': self.end_date.isoformat() if self.end_date else None, + 'departure_time': self.departure_time.isoformat() if self.departure_time else None, + 'return_time': self.return_time.isoformat() if self.return_time else None, + 'country': self.country, + 'city': self.city, + 'full_days': self.full_days, + 'half_days': self.half_days, + 'total_days': self.total_days, + 'trip_duration': self.trip_duration, + 'breakfast_provided': self.breakfast_provided, + 'lunch_provided': self.lunch_provided, + 'dinner_provided': self.dinner_provided, + 'full_day_rate': float(self.full_day_rate), + 'half_day_rate': float(self.half_day_rate), + 'breakfast_deduction': float(self.breakfast_deduction) if self.breakfast_deduction else None, + 'lunch_deduction': float(self.lunch_deduction) if self.lunch_deduction else None, + 'dinner_deduction': float(self.dinner_deduction) if self.dinner_deduction else None, + 'calculated_amount': float(self.calculated_amount), + 'currency_code': self.currency_code, + '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, + 'reimbursed': self.reimbursed, + 'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None, + 'notes': self.notes, + '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 calculate_days_from_dates(cls, start_date, end_date, departure_time=None, return_time=None): + """ + Calculate full and half days based on departure and return times. + + Rules: + - Departure before 12:00 = full day + - Departure after 12:00 = half day + - Return after 12:00 = full day + - Return before 12:00 = half day + - Middle days = full days + """ + from datetime import time as dt_time + + if start_date > end_date: + return {'full_days': 0, 'half_days': 0} + + trip_days = (end_date - start_date).days + 1 + + if trip_days == 1: + # Single day trip + if departure_time and return_time: + # Check if it qualifies for a full day (>= 8 hours) + departure_datetime = datetime.combine(start_date, departure_time) + return_datetime = datetime.combine(end_date, return_time) + hours = (return_datetime - departure_datetime).total_seconds() / 3600 + + if hours >= 8: + return {'full_days': 1, 'half_days': 0} + else: + return {'full_days': 0, 'half_days': 1} + else: + # Default to half day for single day + return {'full_days': 0, 'half_days': 1} + + full_days = 0 + half_days = 0 + + # First day + noon = dt_time(12, 0) + if departure_time and departure_time < noon: + full_days += 1 + else: + half_days += 1 + + # Middle days (all full days) + if trip_days > 2: + full_days += (trip_days - 2) + + # Last day + if return_time and return_time >= noon: + full_days += 1 + else: + half_days += 1 + + return {'full_days': full_days, 'half_days': half_days} + + @classmethod + def get_pending_approvals(cls, user_id=None): + """Get per diem claims 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.start_date.desc()).all() + diff --git a/app/routes/expense_categories.py b/app/routes/expense_categories.py new file mode 100644 index 0000000..6be092b --- /dev/null +++ b/app/routes/expense_categories.py @@ -0,0 +1,253 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +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 ExpenseCategory +from datetime import datetime, date +from decimal import Decimal +from app.utils.db import safe_commit +from app.utils.permissions import admin_or_permission_required + +expense_categories_bp = Blueprint('expense_categories', __name__) + + +@expense_categories_bp.route('/expense-categories') +@login_required +@admin_or_permission_required('expense_categories.view') +def list_categories(): + """List all expense categories""" + from app import track_page_view + track_page_view("expense_categories_list") + + categories = ExpenseCategory.query.order_by(ExpenseCategory.name).all() + + # Get budget utilization for each category + for category in categories: + category.monthly_utilization = category.get_budget_utilization('monthly') + category.yearly_utilization = category.get_budget_utilization('yearly') + + return render_template('expense_categories/list.html', categories=categories) + + +@expense_categories_bp.route('/expense-categories/create', methods=['GET', 'POST']) +@login_required +@admin_or_permission_required('expense_categories.create') +def create_category(): + """Create a new expense category""" + if request.method == 'GET': + return render_template('expense_categories/form.html', category=None) + + try: + # Get form data + name = request.form.get('name', '').strip() + description = request.form.get('description', '').strip() + code = request.form.get('code', '').strip() + color = request.form.get('color', '').strip() + icon = request.form.get('icon', '').strip() + + # Validate required fields + if not name: + flash(_('Category name is required'), 'error') + return redirect(url_for('expense_categories.create_category')) + + # Budget fields + monthly_budget = request.form.get('monthly_budget', '').strip() + quarterly_budget = request.form.get('quarterly_budget', '').strip() + yearly_budget = request.form.get('yearly_budget', '').strip() + budget_threshold_percent = request.form.get('budget_threshold_percent', '80') + + # Settings + requires_receipt = request.form.get('requires_receipt') == 'on' + requires_approval = request.form.get('requires_approval') == 'on' + default_tax_rate = request.form.get('default_tax_rate', '').strip() + is_active = request.form.get('is_active') == 'on' + + # Create category + category = ExpenseCategory( + name=name, + description=description, + code=code if code else None, + color=color if color else None, + icon=icon if icon else None, + monthly_budget=Decimal(monthly_budget) if monthly_budget else None, + quarterly_budget=Decimal(quarterly_budget) if quarterly_budget else None, + yearly_budget=Decimal(yearly_budget) if yearly_budget else None, + budget_threshold_percent=int(budget_threshold_percent) if budget_threshold_percent else 80, + requires_receipt=requires_receipt, + requires_approval=requires_approval, + default_tax_rate=Decimal(default_tax_rate) if default_tax_rate else None, + is_active=is_active + ) + + db.session.add(category) + + if safe_commit(db): + flash(_('Expense category created successfully'), 'success') + log_event('expense_category_created', user_id=current_user.id, category_id=category.id) + track_event(current_user.id, 'expense_category.created', {'category_id': category.id}) + return redirect(url_for('expense_categories.list_categories')) + else: + flash(_('Error creating expense category'), 'error') + return redirect(url_for('expense_categories.create_category')) + + except Exception as e: + from flask import current_app + current_app.logger.error(f"Error creating expense category: {e}") + flash(_('Error creating expense category'), 'error') + return redirect(url_for('expense_categories.create_category')) + + +@expense_categories_bp.route('/expense-categories/') +@login_required +@admin_or_permission_required('expense_categories.view') +def view_category(category_id): + """View expense category details""" + category = ExpenseCategory.query.get_or_404(category_id) + + from app import track_page_view + track_page_view("expense_category_detail", properties={'category_id': category_id}) + + # Get budget utilization + monthly_util = category.get_budget_utilization('monthly') + quarterly_util = category.get_budget_utilization('quarterly') + yearly_util = category.get_budget_utilization('yearly') + + return render_template( + 'expense_categories/view.html', + category=category, + monthly_utilization=monthly_util, + quarterly_utilization=quarterly_util, + yearly_utilization=yearly_util + ) + + +@expense_categories_bp.route('/expense-categories//edit', methods=['GET', 'POST']) +@login_required +@admin_or_permission_required('expense_categories.update') +def edit_category(category_id): + """Edit an expense category""" + category = ExpenseCategory.query.get_or_404(category_id) + + if request.method == 'GET': + return render_template('expense_categories/form.html', category=category) + + try: + # Get form data + name = request.form.get('name', '').strip() + if not name: + flash(_('Category name is required'), 'error') + return redirect(url_for('expense_categories.edit_category', category_id=category_id)) + + # Update category fields + category.name = name + category.description = request.form.get('description', '').strip() + category.code = request.form.get('code', '').strip() or None + category.color = request.form.get('color', '').strip() or None + category.icon = request.form.get('icon', '').strip() or None + + # Budget fields + monthly_budget = request.form.get('monthly_budget', '').strip() + quarterly_budget = request.form.get('quarterly_budget', '').strip() + yearly_budget = request.form.get('yearly_budget', '').strip() + + category.monthly_budget = Decimal(monthly_budget) if monthly_budget else None + category.quarterly_budget = Decimal(quarterly_budget) if quarterly_budget else None + category.yearly_budget = Decimal(yearly_budget) if yearly_budget else None + category.budget_threshold_percent = int(request.form.get('budget_threshold_percent', '80')) + + # Settings + category.requires_receipt = request.form.get('requires_receipt') == 'on' + category.requires_approval = request.form.get('requires_approval') == 'on' + + default_tax_rate = request.form.get('default_tax_rate', '').strip() + category.default_tax_rate = Decimal(default_tax_rate) if default_tax_rate else None + category.is_active = request.form.get('is_active') == 'on' + + category.updated_at = datetime.utcnow() + + if safe_commit(db): + flash(_('Expense category updated successfully'), 'success') + log_event('expense_category_updated', user_id=current_user.id, category_id=category.id) + track_event(current_user.id, 'expense_category.updated', {'category_id': category.id}) + return redirect(url_for('expense_categories.view_category', category_id=category.id)) + else: + flash(_('Error updating expense category'), 'error') + return redirect(url_for('expense_categories.edit_category', category_id=category_id)) + + except Exception as e: + from flask import current_app + current_app.logger.error(f"Error updating expense category: {e}") + flash(_('Error updating expense category'), 'error') + return redirect(url_for('expense_categories.edit_category', category_id=category_id)) + + +@expense_categories_bp.route('/expense-categories//delete', methods=['POST']) +@login_required +@admin_or_permission_required('expense_categories.delete') +def delete_category(category_id): + """Delete an expense category""" + category = ExpenseCategory.query.get_or_404(category_id) + + try: + # Instead of deleting, just deactivate + category.is_active = False + category.updated_at = datetime.utcnow() + + if safe_commit(db): + flash(_('Expense category deactivated successfully'), 'success') + log_event('expense_category_deleted', user_id=current_user.id, category_id=category_id) + track_event(current_user.id, 'expense_category.deleted', {'category_id': category_id}) + else: + flash(_('Error deactivating expense category'), 'error') + + except Exception as e: + from flask import current_app + current_app.logger.error(f"Error deactivating expense category: {e}") + flash(_('Error deactivating expense category'), 'error') + + return redirect(url_for('expense_categories.list_categories')) + + +# API endpoints +@expense_categories_bp.route('/api/expense-categories', methods=['GET']) +@login_required +def api_list_categories(): + """API endpoint to list expense categories""" + categories = ExpenseCategory.get_active_categories() + + return jsonify({ + 'categories': [category.to_dict() for category in categories], + 'count': len(categories) + }) + + +@expense_categories_bp.route('/api/expense-categories/', methods=['GET']) +@login_required +def api_get_category(category_id): + """API endpoint to get a single expense category""" + category = ExpenseCategory.query.get_or_404(category_id) + + return jsonify(category.to_dict()) + + +@expense_categories_bp.route('/api/expense-categories/budget-alerts', methods=['GET']) +@login_required +@admin_or_permission_required('expense_categories.view') +def api_budget_alerts(): + """API endpoint to get categories over budget threshold""" + period = request.args.get('period', 'monthly') + + over_budget = ExpenseCategory.get_categories_over_budget(period) + + return jsonify({ + 'period': period, + 'alerts': [ + { + 'category': item['category'].to_dict(), + 'utilization': item['utilization'] + } + for item in over_budget + ], + 'count': len(over_budget) + }) + diff --git a/app/routes/expenses.py b/app/routes/expenses.py index f4d7390..81c1eac 100644 --- a/app/routes/expenses.py +++ b/app/routes/expenses.py @@ -6,10 +6,12 @@ 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 +from app.utils.ocr import scan_receipt, get_suggested_expense_data, is_ocr_available import csv import io import os from werkzeug.utils import secure_filename +import json expenses_bp = Blueprint('expenses', __name__) @@ -883,3 +885,264 @@ def api_get_expense(expense_id): return jsonify(expense.to_dict()) + +@expenses_bp.route('/api/expenses/scan-receipt', methods=['POST']) +@login_required +def api_scan_receipt(): + """API endpoint to scan a receipt image using OCR""" + if not is_ocr_available(): + return jsonify({ + 'error': 'OCR not available', + 'message': 'Please install Tesseract OCR and pytesseract' + }), 503 + + # Check if file is in request + if 'receipt_file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['receipt_file'] + + if not file or not file.filename: + return jsonify({'error': 'No file selected'}), 400 + + if not allowed_file(file.filename): + return jsonify({'error': 'Invalid file type'}), 400 + + try: + # Save file temporarily + filename = secure_filename(file.filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"temp_{timestamp}_{filename}" + + temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp') + os.makedirs(temp_dir, exist_ok=True) + + temp_path = os.path.join(temp_dir, filename) + file.save(temp_path) + + # Scan receipt + ocr_lang = request.form.get('lang', 'eng') + receipt_data = scan_receipt(temp_path, lang=ocr_lang) + + # Get suggested expense data + suggestions = get_suggested_expense_data(receipt_data) + + # Clean up temp file + try: + os.remove(temp_path) + except Exception: + pass + + # Log event + log_event('receipt_scanned', user_id=current_user.id) + track_event(current_user.id, 'receipt.scanned', { + 'has_amount': bool(receipt_data.get('total')), + 'has_vendor': bool(receipt_data.get('vendor')), + 'has_date': bool(receipt_data.get('date')) + }) + + return jsonify({ + 'success': True, + 'receipt_data': receipt_data, + 'suggestions': suggestions + }) + + except Exception as e: + current_app.logger.error(f"Error scanning receipt: {e}") + return jsonify({ + 'error': 'Failed to scan receipt', + 'message': str(e) + }), 500 + + +@expenses_bp.route('/expenses/scan-receipt', methods=['GET', 'POST']) +@login_required +def scan_receipt_page(): + """Page for scanning receipts with OCR""" + if request.method == 'GET': + return render_template('expenses/scan_receipt.html', ocr_available=is_ocr_available()) + + # POST - handle receipt scanning + if not is_ocr_available(): + flash(_('OCR is not available. Please contact your administrator.'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + if 'receipt_file' not in request.files: + flash(_('No file provided'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + file = request.files['receipt_file'] + + if not file or not file.filename: + flash(_('No file selected'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + if not allowed_file(file.filename): + flash(_('Invalid file type. Allowed types: png, jpg, jpeg, gif, pdf'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + try: + # Save file temporarily + filename = secure_filename(file.filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + temp_filename = f"temp_{timestamp}_{filename}" + + temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp') + os.makedirs(temp_dir, exist_ok=True) + + temp_path = os.path.join(temp_dir, temp_filename) + file.save(temp_path) + + # Scan receipt + ocr_lang = request.form.get('lang', 'eng') + receipt_data = scan_receipt(temp_path, lang=ocr_lang) + + # Get suggested expense data + suggestions = get_suggested_expense_data(receipt_data) + + # Save receipt permanently + filename = f"{timestamp}_{filename}" + upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER) + os.makedirs(upload_dir, exist_ok=True) + + permanent_path = os.path.join(upload_dir, filename) + os.rename(temp_path, permanent_path) + + receipt_path = os.path.join(UPLOAD_FOLDER, filename) + + # Store OCR data in session for use in expense creation + from flask import session + session['scanned_receipt'] = { + 'receipt_path': receipt_path, + 'receipt_data': receipt_data, + 'suggestions': suggestions + } + + # Log event + log_event('receipt_scanned', user_id=current_user.id) + track_event(current_user.id, 'receipt.scanned', { + 'has_amount': bool(receipt_data.get('total')), + 'has_vendor': bool(receipt_data.get('vendor')), + 'has_date': bool(receipt_data.get('date')) + }) + + flash(_('Receipt scanned successfully! You can now create an expense with the extracted data.'), 'success') + return redirect(url_for('expenses.create_expense_from_scan')) + + except Exception as e: + current_app.logger.error(f"Error scanning receipt: {e}") + flash(_('Error scanning receipt. Please try again or enter the expense manually.'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + +@expenses_bp.route('/expenses/create-from-scan', methods=['GET', 'POST']) +@login_required +def create_expense_from_scan(): + """Create expense from scanned receipt data""" + from flask import session + + scanned_data = session.get('scanned_receipt') + + if not scanned_data: + flash(_('No scanned receipt data found. Please scan a receipt first.'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + 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/create_from_scan.html', + expense=None, + projects=projects, + clients=clients, + categories=categories, + payment_methods=payment_methods, + suggestions=scanned_data.get('suggestions', {}), + receipt_data=scanned_data.get('receipt_data', {}) + ) + + # POST - create the expense + try: + # Get form data (similar to create_expense) + 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 all([title, category, amount, expense_date]): + flash(_('Please fill in all required fields'), 'error') + return redirect(url_for('expenses.create_expense_from_scan')) + + # 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_from_scan')) + + # 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_from_scan')) + + # Create expense with OCR data + 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=request.form.get('project_id', type=int), + client_id=request.form.get('client_id', type=int), + payment_method=request.form.get('payment_method', '').strip(), + vendor=request.form.get('vendor', '').strip(), + receipt_number=request.form.get('receipt_number', '').strip(), + receipt_path=scanned_data.get('receipt_path'), + notes=request.form.get('notes', '').strip(), + tags=request.form.get('tags', '').strip(), + billable=request.form.get('billable') == 'on', + reimbursable=request.form.get('reimbursable') == 'on' + ) + + # Store OCR data as JSON + if scanned_data.get('receipt_data'): + # expense.ocr_data = json.dumps(scanned_data['receipt_data']) # Uncomment after migration + pass + + db.session.add(expense) + + if safe_commit(db): + # Clear scanned data from session + session.pop('scanned_receipt', None) + + flash(_('Expense created successfully from scanned receipt'), 'success') + log_event('expense_created_from_scan', user_id=current_user.id, expense_id=expense.id) + track_event(current_user.id, 'expense.created_from_scan', { + 'expense_id': expense.id, + 'category': category, + 'amount': float(amount_decimal) + }) + return redirect(url_for('expenses.view_expense', expense_id=expense.id)) + else: + flash(_('Error creating expense'), 'error') + return redirect(url_for('expenses.create_expense_from_scan')) + + except Exception as e: + current_app.logger.error(f"Error creating expense from scan: {e}") + flash(_('Error creating expense'), 'error') + return redirect(url_for('expenses.create_expense_from_scan')) + diff --git a/app/routes/mileage.py b/app/routes/mileage.py new file mode 100644 index 0000000..0935b16 --- /dev/null +++ b/app/routes/mileage.py @@ -0,0 +1,466 @@ +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 Mileage, Project, Client, Expense +from datetime import datetime, date, timedelta +from decimal import Decimal +from app.utils.db import safe_commit +import csv +import io + +mileage_bp = Blueprint('mileage', __name__) + + +@mileage_bp.route('/mileage') +@login_required +def list_mileage(): + """List all mileage entries with filters""" + from app import track_page_view + track_page_view("mileage_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() + project_id = request.args.get('project_id', type=int) + client_id = request.args.get('client_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() + + # Build query + query = Mileage.query + + # Non-admin users can only see their own mileage or mileage they approved + if not current_user.is_admin: + query = query.filter( + db.or_( + Mileage.user_id == current_user.id, + Mileage.approved_by == current_user.id + ) + ) + + # Apply filters + if status: + query = query.filter(Mileage.status == status) + + if project_id: + query = query.filter(Mileage.project_id == project_id) + + if client_id: + query = query.filter(Mileage.client_id == client_id) + + if start_date: + try: + start = datetime.strptime(start_date, '%Y-%m-%d').date() + query = query.filter(Mileage.trip_date >= start) + except ValueError: + pass + + if end_date: + try: + end = datetime.strptime(end_date, '%Y-%m-%d').date() + query = query.filter(Mileage.trip_date <= end) + except ValueError: + pass + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Mileage.purpose.ilike(like), + Mileage.description.ilike(like), + Mileage.start_location.ilike(like), + Mileage.end_location.ilike(like) + ) + ) + + # Paginate + mileage_pagination = query.order_by(Mileage.trip_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() + + # Calculate totals + total_distance = Mileage.get_total_distance( + user_id=None if current_user.is_admin else current_user.id, + start_date=datetime.strptime(start_date, '%Y-%m-%d').date() if start_date else None, + end_date=datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None + ) + + total_amount_query = db.session.query( + db.func.sum(Mileage.calculated_amount * db.case( + (Mileage.is_round_trip, 2), + else_=1 + )) + ).filter(Mileage.status.in_(['approved', 'reimbursed'])) + + if not current_user.is_admin: + total_amount_query = total_amount_query.filter(Mileage.user_id == current_user.id) + + total_amount = total_amount_query.scalar() or 0 + + return render_template( + 'mileage/list.html', + mileage_entries=mileage_pagination.items, + pagination=mileage_pagination, + projects=projects, + clients=clients, + total_distance=total_distance, + total_amount=float(total_amount), + # Pass back filter values + status=status, + project_id=project_id, + client_id=client_id, + start_date=start_date, + end_date=end_date, + search=search + ) + + +@mileage_bp.route('/mileage/create', methods=['GET', 'POST']) +@login_required +def create_mileage(): + """Create a new mileage entry""" + if request.method == 'GET': + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + default_rates = Mileage.get_default_rates() + + return render_template( + 'mileage/form.html', + mileage=None, + projects=projects, + clients=clients, + default_rates=default_rates + ) + + try: + # Get form data + trip_date = request.form.get('trip_date', '').strip() + purpose = request.form.get('purpose', '').strip() + description = request.form.get('description', '').strip() + start_location = request.form.get('start_location', '').strip() + end_location = request.form.get('end_location', '').strip() + distance_km = request.form.get('distance_km', '').strip() + rate_per_km = request.form.get('rate_per_km', '').strip() + + # Validate required fields + if not all([trip_date, purpose, start_location, end_location, distance_km, rate_per_km]): + flash(_('Please fill in all required fields'), 'error') + return redirect(url_for('mileage.create_mileage')) + + # Parse date + try: + trip_date_obj = datetime.strptime(trip_date, '%Y-%m-%d').date() + except ValueError: + flash(_('Invalid date format'), 'error') + return redirect(url_for('mileage.create_mileage')) + + # Create mileage entry + mileage = Mileage( + user_id=current_user.id, + trip_date=trip_date_obj, + purpose=purpose, + start_location=start_location, + end_location=end_location, + distance_km=Decimal(distance_km), + rate_per_km=Decimal(rate_per_km), + description=description, + project_id=request.form.get('project_id', type=int), + client_id=request.form.get('client_id', type=int), + start_odometer=request.form.get('start_odometer'), + end_odometer=request.form.get('end_odometer'), + vehicle_type=request.form.get('vehicle_type'), + vehicle_description=request.form.get('vehicle_description'), + license_plate=request.form.get('license_plate'), + is_round_trip=request.form.get('is_round_trip') == 'on', + currency_code=request.form.get('currency_code', 'EUR'), + notes=request.form.get('notes') + ) + + db.session.add(mileage) + + # Create expense if requested + if request.form.get('create_expense') == 'on': + expense = mileage.create_expense() + if expense: + db.session.add(expense) + + if safe_commit(db): + flash(_('Mileage entry created successfully'), 'success') + log_event('mileage_created', user_id=current_user.id, mileage_id=mileage.id) + track_event(current_user.id, 'mileage.created', { + 'mileage_id': mileage.id, + 'distance_km': float(distance_km), + 'amount': float(mileage.total_amount) + }) + return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id)) + else: + flash(_('Error creating mileage entry'), 'error') + return redirect(url_for('mileage.create_mileage')) + + except Exception as e: + current_app.logger.error(f"Error creating mileage entry: {e}") + flash(_('Error creating mileage entry'), 'error') + return redirect(url_for('mileage.create_mileage')) + + +@mileage_bp.route('/mileage/') +@login_required +def view_mileage(mileage_id): + """View mileage entry details""" + mileage = Mileage.query.get_or_404(mileage_id) + + # Check permission + if not current_user.is_admin and mileage.user_id != current_user.id and mileage.approved_by != current_user.id: + flash(_('You do not have permission to view this mileage entry'), 'error') + return redirect(url_for('mileage.list_mileage')) + + from app import track_page_view + track_page_view("mileage_detail", properties={'mileage_id': mileage_id}) + + return render_template('mileage/view.html', mileage=mileage) + + +@mileage_bp.route('/mileage//edit', methods=['GET', 'POST']) +@login_required +def edit_mileage(mileage_id): + """Edit a mileage entry""" + mileage = Mileage.query.get_or_404(mileage_id) + + # Check permission + if not current_user.is_admin and mileage.user_id != current_user.id: + flash(_('You do not have permission to edit this mileage entry'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + # Cannot edit approved or reimbursed entries without admin privileges + if not current_user.is_admin and mileage.status in ['approved', 'reimbursed']: + flash(_('Cannot edit approved or reimbursed mileage entries'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + if request.method == 'GET': + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + default_rates = Mileage.get_default_rates() + + return render_template( + 'mileage/form.html', + mileage=mileage, + projects=projects, + clients=clients, + default_rates=default_rates + ) + + try: + # Update fields + trip_date = request.form.get('trip_date', '').strip() + mileage.trip_date = datetime.strptime(trip_date, '%Y-%m-%d').date() + mileage.purpose = request.form.get('purpose', '').strip() + mileage.description = request.form.get('description', '').strip() + mileage.start_location = request.form.get('start_location', '').strip() + mileage.end_location = request.form.get('end_location', '').strip() + mileage.distance_km = Decimal(request.form.get('distance_km', '0')) + mileage.rate_per_km = Decimal(request.form.get('rate_per_km', '0')) + mileage.calculated_amount = mileage.distance_km * mileage.rate_per_km + mileage.project_id = request.form.get('project_id', type=int) + mileage.client_id = request.form.get('client_id', type=int) + mileage.vehicle_type = request.form.get('vehicle_type') + mileage.vehicle_description = request.form.get('vehicle_description') + mileage.license_plate = request.form.get('license_plate') + mileage.is_round_trip = request.form.get('is_round_trip') == 'on' + mileage.currency_code = request.form.get('currency_code', 'EUR') + mileage.notes = request.form.get('notes') + mileage.updated_at = datetime.utcnow() + + if safe_commit(db): + flash(_('Mileage entry updated successfully'), 'success') + log_event('mileage_updated', user_id=current_user.id, mileage_id=mileage.id) + track_event(current_user.id, 'mileage.updated', {'mileage_id': mileage.id}) + return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id)) + else: + flash(_('Error updating mileage entry'), 'error') + return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id)) + + except Exception as e: + current_app.logger.error(f"Error updating mileage entry: {e}") + flash(_('Error updating mileage entry'), 'error') + return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id)) + + +@mileage_bp.route('/mileage//delete', methods=['POST']) +@login_required +def delete_mileage(mileage_id): + """Delete a mileage entry""" + mileage = Mileage.query.get_or_404(mileage_id) + + # Check permission + if not current_user.is_admin and mileage.user_id != current_user.id: + flash(_('You do not have permission to delete this mileage entry'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + try: + db.session.delete(mileage) + + if safe_commit(db): + flash(_('Mileage entry deleted successfully'), 'success') + log_event('mileage_deleted', user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, 'mileage.deleted', {'mileage_id': mileage_id}) + else: + flash(_('Error deleting mileage entry'), 'error') + + except Exception as e: + current_app.logger.error(f"Error deleting mileage entry: {e}") + flash(_('Error deleting mileage entry'), 'error') + + return redirect(url_for('mileage.list_mileage')) + + +@mileage_bp.route('/mileage//approve', methods=['POST']) +@login_required +def approve_mileage(mileage_id): + """Approve a mileage entry""" + if not current_user.is_admin: + flash(_('Only administrators can approve mileage entries'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + mileage = Mileage.query.get_or_404(mileage_id) + + if mileage.status != 'pending': + flash(_('Only pending mileage entries can be approved'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + try: + notes = request.form.get('approval_notes', '').strip() + mileage.approve(current_user.id, notes) + + if safe_commit(db): + flash(_('Mileage entry approved successfully'), 'success') + log_event('mileage_approved', user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, 'mileage.approved', {'mileage_id': mileage_id}) + else: + flash(_('Error approving mileage entry'), 'error') + + except Exception as e: + current_app.logger.error(f"Error approving mileage entry: {e}") + flash(_('Error approving mileage entry'), 'error') + + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + +@mileage_bp.route('/mileage//reject', methods=['POST']) +@login_required +def reject_mileage(mileage_id): + """Reject a mileage entry""" + if not current_user.is_admin: + flash(_('Only administrators can reject mileage entries'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + mileage = Mileage.query.get_or_404(mileage_id) + + if mileage.status != 'pending': + flash(_('Only pending mileage entries can be rejected'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + try: + reason = request.form.get('rejection_reason', '').strip() + if not reason: + flash(_('Rejection reason is required'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + mileage.reject(current_user.id, reason) + + if safe_commit(db): + flash(_('Mileage entry rejected'), 'success') + log_event('mileage_rejected', user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, 'mileage.rejected', {'mileage_id': mileage_id}) + else: + flash(_('Error rejecting mileage entry'), 'error') + + except Exception as e: + current_app.logger.error(f"Error rejecting mileage entry: {e}") + flash(_('Error rejecting mileage entry'), 'error') + + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + +@mileage_bp.route('/mileage//reimburse', methods=['POST']) +@login_required +def mark_reimbursed(mileage_id): + """Mark a mileage entry as reimbursed""" + if not current_user.is_admin: + flash(_('Only administrators can mark mileage entries as reimbursed'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + mileage = Mileage.query.get_or_404(mileage_id) + + if mileage.status != 'approved': + flash(_('Only approved mileage entries can be marked as reimbursed'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + try: + mileage.mark_as_reimbursed() + + if safe_commit(db): + flash(_('Mileage entry marked as reimbursed'), 'success') + log_event('mileage_reimbursed', user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, 'mileage.reimbursed', {'mileage_id': mileage_id}) + else: + flash(_('Error marking mileage entry as reimbursed'), 'error') + + except Exception as e: + current_app.logger.error(f"Error marking mileage entry as reimbursed: {e}") + flash(_('Error marking mileage entry as reimbursed'), 'error') + + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + +# API endpoints +@mileage_bp.route('/api/mileage', methods=['GET']) +@login_required +def api_list_mileage(): + """API endpoint to list mileage entries""" + status = request.args.get('status', '').strip() + + query = Mileage.query + + if not current_user.is_admin: + query = query.filter_by(user_id=current_user.id) + + if status: + query = query.filter(Mileage.status == status) + + entries = query.order_by(Mileage.trip_date.desc()).all() + + return jsonify({ + 'mileage': [entry.to_dict() for entry in entries], + 'count': len(entries) + }) + + +@mileage_bp.route('/api/mileage/', methods=['GET']) +@login_required +def api_get_mileage(mileage_id): + """API endpoint to get a single mileage entry""" + mileage = Mileage.query.get_or_404(mileage_id) + + # Check permission + if not current_user.is_admin and mileage.user_id != current_user.id: + return jsonify({'error': 'Permission denied'}), 403 + + return jsonify(mileage.to_dict()) + + +@mileage_bp.route('/api/mileage/default-rates', methods=['GET']) +@login_required +def api_get_default_rates(): + """API endpoint to get default mileage rates""" + return jsonify(Mileage.get_default_rates()) + diff --git a/app/routes/per_diem.py b/app/routes/per_diem.py new file mode 100644 index 0000000..16db840 --- /dev/null +++ b/app/routes/per_diem.py @@ -0,0 +1,542 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, 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 PerDiem, PerDiemRate, Project, Client +from datetime import datetime, date, time +from decimal import Decimal +from app.utils.db import safe_commit +from app.utils.permissions import admin_or_permission_required + +per_diem_bp = Blueprint('per_diem', __name__) + + +@per_diem_bp.route('/per-diem') +@login_required +def list_per_diem(): + """List all per diem claims with filters""" + from app import track_page_view + track_page_view("per_diem_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() + project_id = request.args.get('project_id', type=int) + client_id = request.args.get('client_id', type=int) + start_date = request.args.get('start_date', '').strip() + end_date = request.args.get('end_date', '').strip() + + # Build query + query = PerDiem.query + + # Non-admin users can only see their own claims + if not current_user.is_admin: + query = query.filter( + db.or_( + PerDiem.user_id == current_user.id, + PerDiem.approved_by == current_user.id + ) + ) + + # Apply filters + if status: + query = query.filter(PerDiem.status == status) + + if project_id: + query = query.filter(PerDiem.project_id == project_id) + + if client_id: + query = query.filter(PerDiem.client_id == client_id) + + if start_date: + try: + start = datetime.strptime(start_date, '%Y-%m-%d').date() + query = query.filter(PerDiem.start_date >= start) + except ValueError: + pass + + if end_date: + try: + end = datetime.strptime(end_date, '%Y-%m-%d').date() + query = query.filter(PerDiem.end_date <= end) + except ValueError: + pass + + # Paginate + per_diem_pagination = query.order_by(PerDiem.start_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() + + # Calculate totals + total_amount_query = db.session.query( + db.func.sum(PerDiem.calculated_amount) + ).filter(PerDiem.status.in_(['approved', 'reimbursed'])) + + if not current_user.is_admin: + total_amount_query = total_amount_query.filter(PerDiem.user_id == current_user.id) + + total_amount = total_amount_query.scalar() or 0 + + return render_template( + 'per_diem/list.html', + per_diem_claims=per_diem_pagination.items, + pagination=per_diem_pagination, + projects=projects, + clients=clients, + total_amount=float(total_amount), + status=status, + project_id=project_id, + client_id=client_id, + start_date=start_date, + end_date=end_date + ) + + +@per_diem_bp.route('/per-diem/create', methods=['GET', 'POST']) +@login_required +def create_per_diem(): + """Create a new per diem claim""" + if request.method == 'GET': + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + + return render_template( + 'per_diem/form.html', + per_diem=None, + projects=projects, + clients=clients + ) + + try: + # Get form data + trip_purpose = request.form.get('trip_purpose', '').strip() + start_date_str = request.form.get('start_date', '').strip() + end_date_str = request.form.get('end_date', '').strip() + country = request.form.get('country', '').strip() + city = request.form.get('city', '').strip() + + # Validate required fields + if not all([trip_purpose, start_date_str, end_date_str, country]): + flash(_('Please fill in all required fields'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + # Parse dates + try: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + except ValueError: + flash(_('Invalid date format'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + if start_date > end_date: + flash(_('Start date must be before end date'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + # Parse times if provided + departure_time = None + return_time = None + departure_time_str = request.form.get('departure_time', '').strip() + return_time_str = request.form.get('return_time', '').strip() + + if departure_time_str: + try: + departure_time = datetime.strptime(departure_time_str, '%H:%M').time() + except ValueError: + pass + + if return_time_str: + try: + return_time = datetime.strptime(return_time_str, '%H:%M').time() + except ValueError: + pass + + # Get or calculate full/half days + auto_calculate = request.form.get('auto_calculate_days') == 'on' + + if auto_calculate: + days_calc = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time) + full_days = days_calc['full_days'] + half_days = days_calc['half_days'] + else: + full_days = int(request.form.get('full_days', 0)) + half_days = int(request.form.get('half_days', 0)) + + # Get applicable rate + rate = PerDiemRate.get_rate_for_location(country, city, start_date) + + if not rate: + flash(_('No per diem rate found for this location. Please configure rates first.'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + # Meal deductions + breakfast_provided = int(request.form.get('breakfast_provided', 0)) + lunch_provided = int(request.form.get('lunch_provided', 0)) + dinner_provided = int(request.form.get('dinner_provided', 0)) + + # Create per diem claim + per_diem = PerDiem( + user_id=current_user.id, + trip_purpose=trip_purpose, + start_date=start_date, + end_date=end_date, + country=country, + city=city, + full_day_rate=rate.full_day_rate, + half_day_rate=rate.half_day_rate, + description=request.form.get('description'), + project_id=request.form.get('project_id', type=int), + client_id=request.form.get('client_id', type=int), + per_diem_rate_id=rate.id, + departure_time=departure_time, + return_time=return_time, + full_days=full_days, + half_days=half_days, + breakfast_provided=breakfast_provided, + lunch_provided=lunch_provided, + dinner_provided=dinner_provided, + breakfast_deduction=rate.breakfast_rate or Decimal('0'), + lunch_deduction=rate.lunch_rate or Decimal('0'), + dinner_deduction=rate.dinner_rate or Decimal('0'), + currency_code=rate.currency_code, + notes=request.form.get('notes') + ) + + db.session.add(per_diem) + + # Create expense if requested + if request.form.get('create_expense') == 'on': + expense = per_diem.create_expense() + if expense: + db.session.add(expense) + + if safe_commit(db): + flash(_('Per diem claim created successfully'), 'success') + log_event('per_diem_created', user_id=current_user.id, per_diem_id=per_diem.id) + track_event(current_user.id, 'per_diem.created', { + 'per_diem_id': per_diem.id, + 'amount': float(per_diem.calculated_amount), + 'days': per_diem.total_days + }) + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id)) + else: + flash(_('Error creating per diem claim'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + except Exception as e: + current_app.logger.error(f"Error creating per diem claim: {e}") + flash(_('Error creating per diem claim'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + +@per_diem_bp.route('/per-diem/') +@login_required +def view_per_diem(per_diem_id): + """View per diem claim details""" + per_diem = PerDiem.query.get_or_404(per_diem_id) + + # Check permission + if not current_user.is_admin and per_diem.user_id != current_user.id and per_diem.approved_by != current_user.id: + flash(_('You do not have permission to view this per diem claim'), 'error') + return redirect(url_for('per_diem.list_per_diem')) + + from app import track_page_view + track_page_view("per_diem_detail", properties={'per_diem_id': per_diem_id}) + + return render_template('per_diem/view.html', per_diem=per_diem) + + +@per_diem_bp.route('/per-diem//edit', methods=['GET', 'POST']) +@login_required +def edit_per_diem(per_diem_id): + """Edit a per diem claim""" + per_diem = PerDiem.query.get_or_404(per_diem_id) + + # Check permission + if not current_user.is_admin and per_diem.user_id != current_user.id: + flash(_('You do not have permission to edit this per diem claim'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + # Cannot edit approved or reimbursed claims without admin privileges + if not current_user.is_admin and per_diem.status in ['approved', 'reimbursed']: + flash(_('Cannot edit approved or reimbursed per diem claims'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + if request.method == 'GET': + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + + return render_template( + 'per_diem/form.html', + per_diem=per_diem, + projects=projects, + clients=clients + ) + + try: + # Update fields + per_diem.trip_purpose = request.form.get('trip_purpose', '').strip() + per_diem.description = request.form.get('description', '').strip() + per_diem.start_date = datetime.strptime(request.form.get('start_date'), '%Y-%m-%d').date() + per_diem.end_date = datetime.strptime(request.form.get('end_date'), '%Y-%m-%d').date() + per_diem.country = request.form.get('country', '').strip() + per_diem.city = request.form.get('city', '').strip() + per_diem.project_id = request.form.get('project_id', type=int) + per_diem.client_id = request.form.get('client_id', type=int) + per_diem.full_days = int(request.form.get('full_days', 0)) + per_diem.half_days = int(request.form.get('half_days', 0)) + per_diem.breakfast_provided = int(request.form.get('breakfast_provided', 0)) + per_diem.lunch_provided = int(request.form.get('lunch_provided', 0)) + per_diem.dinner_provided = int(request.form.get('dinner_provided', 0)) + per_diem.notes = request.form.get('notes') + per_diem.updated_at = datetime.utcnow() + + # Recalculate amount + per_diem.recalculate_amount() + + if safe_commit(db): + flash(_('Per diem claim updated successfully'), 'success') + log_event('per_diem_updated', user_id=current_user.id, per_diem_id=per_diem.id) + track_event(current_user.id, 'per_diem.updated', {'per_diem_id': per_diem.id}) + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id)) + else: + flash(_('Error updating per diem claim'), 'error') + return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id)) + + except Exception as e: + current_app.logger.error(f"Error updating per diem claim: {e}") + flash(_('Error updating per diem claim'), 'error') + return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id)) + + +@per_diem_bp.route('/per-diem//delete', methods=['POST']) +@login_required +def delete_per_diem(per_diem_id): + """Delete a per diem claim""" + per_diem = PerDiem.query.get_or_404(per_diem_id) + + # Check permission + if not current_user.is_admin and per_diem.user_id != current_user.id: + flash(_('You do not have permission to delete this per diem claim'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + try: + db.session.delete(per_diem) + + if safe_commit(db): + flash(_('Per diem claim deleted successfully'), 'success') + log_event('per_diem_deleted', user_id=current_user.id, per_diem_id=per_diem_id) + track_event(current_user.id, 'per_diem.deleted', {'per_diem_id': per_diem_id}) + else: + flash(_('Error deleting per diem claim'), 'error') + + except Exception as e: + current_app.logger.error(f"Error deleting per diem claim: {e}") + flash(_('Error deleting per diem claim'), 'error') + + return redirect(url_for('per_diem.list_per_diem')) + + +@per_diem_bp.route('/per-diem//approve', methods=['POST']) +@login_required +def approve_per_diem(per_diem_id): + """Approve a per diem claim""" + if not current_user.is_admin: + flash(_('Only administrators can approve per diem claims'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + per_diem = PerDiem.query.get_or_404(per_diem_id) + + if per_diem.status != 'pending': + flash(_('Only pending per diem claims can be approved'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + try: + notes = request.form.get('approval_notes', '').strip() + per_diem.approve(current_user.id, notes) + + if safe_commit(db): + flash(_('Per diem claim approved successfully'), 'success') + log_event('per_diem_approved', user_id=current_user.id, per_diem_id=per_diem_id) + track_event(current_user.id, 'per_diem.approved', {'per_diem_id': per_diem_id}) + else: + flash(_('Error approving per diem claim'), 'error') + + except Exception as e: + current_app.logger.error(f"Error approving per diem claim: {e}") + flash(_('Error approving per diem claim'), 'error') + + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + +@per_diem_bp.route('/per-diem//reject', methods=['POST']) +@login_required +def reject_per_diem(per_diem_id): + """Reject a per diem claim""" + if not current_user.is_admin: + flash(_('Only administrators can reject per diem claims'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + per_diem = PerDiem.query.get_or_404(per_diem_id) + + if per_diem.status != 'pending': + flash(_('Only pending per diem claims can be rejected'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + try: + reason = request.form.get('rejection_reason', '').strip() + if not reason: + flash(_('Rejection reason is required'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + per_diem.reject(current_user.id, reason) + + if safe_commit(db): + flash(_('Per diem claim rejected'), 'success') + log_event('per_diem_rejected', user_id=current_user.id, per_diem_id=per_diem_id) + track_event(current_user.id, 'per_diem.rejected', {'per_diem_id': per_diem_id}) + else: + flash(_('Error rejecting per diem claim'), 'error') + + except Exception as e: + current_app.logger.error(f"Error rejecting per diem claim: {e}") + flash(_('Error rejecting per diem claim'), 'error') + + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + +# Per Diem Rates Management +@per_diem_bp.route('/per-diem/rates') +@login_required +@admin_or_permission_required('per_diem_rates.view') +def list_rates(): + """List all per diem rates""" + from app import track_page_view + track_page_view("per_diem_rates_list") + + rates = PerDiemRate.query.filter_by(is_active=True).order_by( + PerDiemRate.country, PerDiemRate.city, PerDiemRate.effective_from.desc() + ).all() + + return render_template('per_diem/rates_list.html', rates=rates) + + +@per_diem_bp.route('/per-diem/rates/create', methods=['GET', 'POST']) +@login_required +@admin_or_permission_required('per_diem_rates.create') +def create_rate(): + """Create a new per diem rate""" + if request.method == 'GET': + return render_template('per_diem/rate_form.html', rate=None) + + try: + country = request.form.get('country', '').strip() + full_day_rate = request.form.get('full_day_rate', '').strip() + half_day_rate = request.form.get('half_day_rate', '').strip() + effective_from = request.form.get('effective_from', '').strip() + + if not all([country, full_day_rate, half_day_rate, effective_from]): + flash(_('Please fill in all required fields'), 'error') + return redirect(url_for('per_diem.create_rate')) + + rate = PerDiemRate( + country=country, + city=request.form.get('city'), + full_day_rate=Decimal(full_day_rate), + half_day_rate=Decimal(half_day_rate), + breakfast_rate=request.form.get('breakfast_rate') or None, + lunch_rate=request.form.get('lunch_rate') or None, + dinner_rate=request.form.get('dinner_rate') or None, + incidental_rate=request.form.get('incidental_rate') or None, + currency_code=request.form.get('currency_code', 'EUR'), + effective_from=datetime.strptime(effective_from, '%Y-%m-%d').date(), + effective_to=datetime.strptime(request.form.get('effective_to'), '%Y-%m-%d').date() if request.form.get('effective_to') else None, + notes=request.form.get('notes') + ) + + db.session.add(rate) + + if safe_commit(db): + flash(_('Per diem rate created successfully'), 'success') + log_event('per_diem_rate_created', user_id=current_user.id, rate_id=rate.id) + return redirect(url_for('per_diem.list_rates')) + else: + flash(_('Error creating per diem rate'), 'error') + return redirect(url_for('per_diem.create_rate')) + + except Exception as e: + current_app.logger.error(f"Error creating per diem rate: {e}") + flash(_('Error creating per diem rate'), 'error') + return redirect(url_for('per_diem.create_rate')) + + +# API endpoints +@per_diem_bp.route('/api/per-diem', methods=['GET']) +@login_required +def api_list_per_diem(): + """API endpoint to list per diem claims""" + status = request.args.get('status', '').strip() + + query = PerDiem.query + + if not current_user.is_admin: + query = query.filter_by(user_id=current_user.id) + + if status: + query = query.filter(PerDiem.status == status) + + claims = query.order_by(PerDiem.start_date.desc()).all() + + return jsonify({ + 'per_diem': [claim.to_dict() for claim in claims], + 'count': len(claims) + }) + + +@per_diem_bp.route('/api/per-diem/rates/search', methods=['GET']) +@login_required +def api_search_rates(): + """API endpoint to search for per diem rates""" + country = request.args.get('country', '').strip() + city = request.args.get('city', '').strip() + date_str = request.args.get('date', '').strip() + + if not country: + return jsonify({'error': 'Country is required'}), 400 + + search_date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else date.today() + + rate = PerDiemRate.get_rate_for_location(country, city, search_date) + + if rate: + return jsonify(rate.to_dict()) + else: + return jsonify({'error': 'No rate found for this location'}), 404 + + +@per_diem_bp.route('/api/per-diem/calculate-days', methods=['POST']) +@login_required +def api_calculate_days(): + """API endpoint to calculate full/half days from dates and times""" + data = request.get_json() + + try: + start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() + end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date() + departure_time = datetime.strptime(data.get('departure_time', ''), '%H:%M').time() if data.get('departure_time') else None + return_time = datetime.strptime(data.get('return_time', ''), '%H:%M').time() if data.get('return_time') else None + + result = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time) + + return jsonify(result) + + except Exception as e: + return jsonify({'error': str(e)}), 400 + diff --git a/app/templates/base.html b/app/templates/base.html index 339c1c5..309b79e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -102,7 +102,7 @@ {% 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 finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') %} {% set analytics_open = ep.startswith('analytics.') %} - {% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') %} + {% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) %}
+
+ + + +{% endblock %} + diff --git a/app/templates/expense_categories/list.html b/app/templates/expense_categories/list.html new file mode 100644 index 0000000..0f17ce8 --- /dev/null +++ b/app/templates/expense_categories/list.html @@ -0,0 +1,160 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Expense Categories'} +] %} + +{{ page_header( + icon_class='fas fa-tags', + title_text='Expense Categories', + subtitle_text='Manage expense categories and budgets', + breadcrumbs=breadcrumbs, + actions_html='New Category' +) }} + + +
+
+
+
+

Total Categories

+

{{ categories|length }}

+
+
+ +
+
+
+ +
+
+
+

Active Categories

+

{{ categories|selectattr('is_active')|list|length }}

+
+
+ +
+
+
+ +
+
+
+

With Budgets

+

{{ categories|selectattr('monthly_budget')|list|length }}

+
+
+ +
+
+
+
+ + +
+
+ + + + + + + + + + + + + {% if categories %} + {% for category in categories %} + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
CategoryCodeMonthly BudgetUtilizationStatusActions
+
+ {% if category.icon %} +
+ +
+ {% endif %} +
+ + {{ category.name }} + + {% if category.description %} +
{{ category.description[:50] }}{% if category.description|length > 50 %}...{% endif %}
+ {% endif %} +
+
+
+ {% if category.code %} + + {{ category.code }} + + {% else %} + - + {% endif %} + + {% if category.monthly_budget %} + €{{ '%.2f'|format(category.monthly_budget) }} + {% else %} + No budget + {% endif %} + + {% set util = category.monthly_utilization if category.monthly_utilization is not none else None %} + {% if util is not none %} +
+
+
+
+ {{ util }}% +
+ {% else %} + - + {% endif %} +
+ {% if category.is_active %} + + Active + + {% else %} + + Inactive + + {% endif %} + + +
+ +

No expense categories found

+ + Create your first category + +
+
+
+ +{% endblock %} + diff --git a/app/templates/expense_categories/view.html b/app/templates/expense_categories/view.html new file mode 100644 index 0000000..2b2c722 --- /dev/null +++ b/app/templates/expense_categories/view.html @@ -0,0 +1,208 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Expense Categories', 'url': url_for('expense_categories.list_categories')}, + {'text': category.name} +] %} + +{{ page_header( + icon_class='fas fa-tags', + title_text=category.name, + subtitle_text=category.description if category.description else 'Expense Category Details', + breadcrumbs=breadcrumbs, + actions_html='Edit Category' +) }} + +
+ +
+ +
+

+ Basic Information +

+ +
+
+

Category Name

+
+ {% if category.icon %} +
+ +
+ {% endif %} +

{{ category.name }}

+
+
+ +
+

Code

+

{{ category.code if category.code else '-' }}

+
+ +
+

Description

+

{{ category.description if category.description else '-' }}

+
+ +
+

Status

+ {% if category.is_active %} + + Active + + {% else %} + + Inactive + + {% endif %} +
+ +
+

Default Tax Rate

+

{{ '%.2f'|format(category.default_tax_rate) if category.default_tax_rate else '-' }}%

+
+
+
+ + + {% if category.monthly_budget or category.quarterly_budget or category.yearly_budget %} +
+

+ Budget & Utilization +

+ +
+ {% if category.monthly_budget %} +
+
+ Monthly Budget + €{{ '%.2f'|format(category.monthly_budget) }} +
+ {% if monthly_utilization is defined %} +
+
+ {{ monthly_utilization }}% +
+
+ {% endif %} +
+ {% endif %} + + {% if category.quarterly_budget %} +
+
+ Quarterly Budget + €{{ '%.2f'|format(category.quarterly_budget) }} +
+ {% if quarterly_utilization is defined %} +
+
+ {{ quarterly_utilization }}% +
+
+ {% endif %} +
+ {% endif %} + + {% if category.yearly_budget %} +
+
+ Yearly Budget + €{{ '%.2f'|format(category.yearly_budget) }} +
+ {% if yearly_utilization is defined %} +
+
+ {{ yearly_utilization }}% +
+
+ {% endif %} +
+ {% endif %} + +
+

+ + Alert Threshold: {{ category.budget_threshold_percent }}% +

+
+
+
+ {% endif %} +
+ + +
+ +
+

+ Settings +

+ +
+
+ Requires Receipt + {% if category.requires_receipt %} + Yes + {% else %} + No + {% endif %} +
+ +
+ Requires Approval + {% if category.requires_approval %} + Yes + {% else %} + No + {% endif %} +
+
+
+ + +
+

+ Metadata +

+ +
+
+

Created

+

{{ category.created_at.strftime('%Y-%m-%d %H:%M') if category.created_at else '-' }}

+
+ +
+

Last Updated

+

{{ category.updated_at.strftime('%Y-%m-%d %H:%M') if category.updated_at else '-' }}

+
+
+
+ + +
+

+ Actions +

+ +
+ + Edit Category + + +
+ + +
+
+
+
+
+ +{% endblock %} + diff --git a/app/templates/expenses/list.html b/app/templates/expenses/list.html index 5d595b7..f12fb7e 100644 --- a/app/templates/expenses/list.html +++ b/app/templates/expenses/list.html @@ -11,7 +11,12 @@ title_text='Expenses', subtitle_text='Track and manage business expenses', breadcrumbs=breadcrumbs, - actions_html='New Expense' + actions_html='' + + '
' + + 'New Expense' + + 'New Mileage' + + 'New Per Diem' + + '
' ) }} diff --git a/app/templates/mileage/form.html b/app/templates/mileage/form.html new file mode 100644 index 0000000..2f261c5 --- /dev/null +++ b/app/templates/mileage/form.html @@ -0,0 +1,309 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Mileage', 'url': url_for('mileage.list_mileage')}, + {'text': 'Edit' if mileage else 'New'} +] %} + +{{ page_header( + icon_class='fas fa-car', + title_text=('Edit Mileage Entry' if mileage else 'New Mileage Entry'), + subtitle_text=('Update mileage details' if mileage else 'Record a new vehicle mileage entry'), + breadcrumbs=breadcrumbs +) }} + +
+
+ + + +
+

+ Trip Information +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

+ Route Details +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

Standard rate: €0.30/km

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

+ + Calculated Amount: 0.00 EUR +

+
+
+ + +
+

+ Vehicle Information +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

+ Association +

+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+ + +
+ + + {% if not mileage %} +
+
+ + +
+
+ {% endif %} + + +
+ + Cancel + + + +
+
+
+ + + +{% endblock %} + diff --git a/app/templates/mileage/list.html b/app/templates/mileage/list.html new file mode 100644 index 0000000..61200b8 --- /dev/null +++ b/app/templates/mileage/list.html @@ -0,0 +1,214 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Mileage'} +] %} + +{{ page_header( + icon_class='fas fa-car', + title_text='Mileage Tracking', + subtitle_text='Track and manage vehicle mileage expenses', + breadcrumbs=breadcrumbs, + actions_html='New Mileage Entry' +) }} + + +
+
+
+
+

Total Distance

+

{{ '%.2f'|format(total_distance) }} km

+
+
+ +
+
+
+ +
+
+
+

Total Amount

+

€{{ '%.2f'|format(total_amount) }}

+
+
+ +
+
+
+ +
+
+
+

Total Entries

+

{{ pagination.total if pagination else (mileage_entries|length) }}

+
+
+ +
+
+
+
+ + +
+

Filter Mileage

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+
+ + +
+
+ + + + + + + + + + + + + + {% if mileage_entries %} + {% for entry in mileage_entries %} + + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
DatePurposeRouteDistanceAmountStatusActions
+ {{ entry.date.strftime('%Y-%m-%d') if entry.date else (entry.trip_date.strftime('%Y-%m-%d') if entry.trip_date else '-') }} + + + {{ entry.purpose }} + + {% if entry.project %} +
{{ entry.project.name }}
+ {% endif %} +
+
+ {{ entry.start_location }} + + {{ entry.end_location }} +
+
+ {{ '%.2f'|format(entry.distance_km) }} km + + {{ entry.currency_code or 'EUR' }} {{ '%.2f'|format(entry.total_amount or (entry.calculated_amount or 0)) }} + + {% if entry.status == 'pending' %} + + Pending + + {% elif entry.status == 'approved' %} + + Approved + + {% elif entry.status == 'rejected' %} + + Rejected + + {% elif entry.status == 'reimbursed' %} + + Reimbursed + + {% endif %} + +
+ + + + {% if current_user.is_admin or entry.user_id == current_user.id %} + + + + {% endif %} +
+
+ +

No mileage entries found

+ + Create your first mileage entry + +
+
+
+ +{% endblock %} + diff --git a/app/templates/mileage/view.html b/app/templates/mileage/view.html new file mode 100644 index 0000000..1f0d7fb --- /dev/null +++ b/app/templates/mileage/view.html @@ -0,0 +1,302 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Mileage', 'url': url_for('mileage.list_mileage')}, + {'text': 'Mileage #' + mileage.id|string} +] %} + +{{ page_header( + icon_class='fas fa-car', + title_text='Mileage Entry #' + mileage.id|string, + subtitle_text=mileage.purpose, + breadcrumbs=breadcrumbs, + actions_html='Edit' if current_user.is_admin or mileage.user_id == current_user.id else '' +) }} + +
+ +
+ +
+

+ Trip Details +

+ +
+
+

Trip Date

+

{{ mileage.trip_date.strftime('%Y-%m-%d') if mileage.trip_date else (mileage.date.strftime('%Y-%m-%d') if mileage.date else '-') }}

+
+ +
+

User

+

{{ mileage.user.full_name if mileage.user and mileage.user.full_name else (mileage.user.username if mileage.user else '-') }}

+
+ +
+

Purpose

+

{{ mileage.purpose }}

+
+ + {% if mileage.description %} +
+

Description

+

{{ mileage.description }}

+
+ {% endif %} + +
+

Route

+
+
+
+ + {{ mileage.start_location }} +
+
+ + {{ '%.2f'|format(mileage.distance_km) }} km +
+
+ + {{ mileage.end_location }} +
+
+ {% if mileage.is_round_trip %} +
+ Round Trip +
+ {% endif %} +
+
+ +
+

Distance

+

{{ '%.2f'|format(mileage.distance_km) }} km

+ {% if mileage.is_round_trip %} +

Total: {{ '%.2f'|format(mileage.distance_km * 2) }} km

+ {% endif %} +
+ +
+

Rate per km

+

{{ mileage.currency_code or 'EUR' }} {{ '%.2f'|format(mileage.rate_per_km) }}

+
+ +
+
+

Total Amount

+

{{ mileage.currency_code or 'EUR' }} {{ '%.2f'|format(mileage.total_amount or (mileage.calculated_amount or 0)) }}

+
+
+
+
+ + + {% if mileage.vehicle_type or mileage.vehicle_description or mileage.license_plate %} +
+

+ Vehicle Information +

+ +
+ {% if mileage.vehicle_type %} +
+

Type

+

{{ mileage.vehicle_type|title }}

+
+ {% endif %} + + {% if mileage.vehicle_description %} +
+

Make/Model

+

{{ mileage.vehicle_description }}

+
+ {% endif %} + + {% if mileage.license_plate %} +
+

License Plate

+

{{ mileage.license_plate }}

+
+ {% endif %} + + {% if mileage.start_odometer and mileage.end_odometer %} +
+

Start Odometer

+

{{ mileage.start_odometer }}

+
+ +
+

End Odometer

+

{{ mileage.end_odometer }}

+
+ {% endif %} +
+
+ {% endif %} + + + {% if mileage.project or mileage.client %} +
+

+ Association +

+ +
+ {% if mileage.project %} + + {% endif %} + + {% if mileage.client %} + + {% endif %} +
+
+ {% endif %} + + + {% if mileage.notes %} +
+

+ Notes +

+

{{ mileage.notes }}

+
+ {% endif %} +
+ + +
+ +
+

+ Status +

+ +
+ {% if mileage.status == 'pending' %} + + Pending Approval + + {% elif mileage.status == 'approved' %} + + Approved + + {% elif mileage.status == 'rejected' %} + + Rejected + + {% elif mileage.status == 'reimbursed' %} + + Reimbursed + + {% endif %} +
+ + {% if mileage.approved_by %} +
+

{% if mileage.status == 'approved' %}Approved By{% else %}Reviewed By{% endif %}

+

{{ mileage.approver.full_name if mileage.approver and mileage.approver.full_name else (mileage.approver.username if mileage.approver else '-') }}

+ {% if mileage.approved_at %} +

{{ mileage.approved_at.strftime('%Y-%m-%d %H:%M') }}

+ {% endif %} +
+ {% endif %} + + {% if mileage.status == 'rejected' and mileage.rejection_reason %} +
+

+ Rejection Reason:
+ {{ mileage.rejection_reason }} +

+
+ {% endif %} + + {% if mileage.approval_notes %} +
+

+ Approval Notes:
+ {{ mileage.approval_notes }} +

+
+ {% endif %} +
+ + + {% if current_user.is_admin and mileage.status == 'pending' %} +
+

+ Admin Actions +

+ +
+ + + +
+ +
+ + + +
+
+ {% endif %} + + {% if current_user.is_admin and mileage.status == 'approved' %} +
+

+ Reimbursement +

+ +
+ + +
+
+ {% endif %} + + +
+

+ Metadata +

+ +
+
+

Created

+

{{ mileage.created_at.strftime('%Y-%m-%d %H:%M') if mileage.created_at else '-' }}

+
+ +
+

Last Updated

+

{{ mileage.updated_at.strftime('%Y-%m-%d %H:%M') if mileage.updated_at else '-' }}

+
+
+
+
+
+ +{% endblock %} + diff --git a/app/templates/per_diem/form.html b/app/templates/per_diem/form.html new file mode 100644 index 0000000..ded6f3e --- /dev/null +++ b/app/templates/per_diem/form.html @@ -0,0 +1,230 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')}, + {'text': 'Edit' if per_diem else 'New'} +] %} + +{{ page_header( + icon_class='fas fa-money-bill-alt', + title_text=('Edit Per Diem Claim' if per_diem else 'New Per Diem Claim'), + subtitle_text=('Update claim details' if per_diem else 'Create a new per diem claim'), + breadcrumbs=breadcrumbs +) }} + +
+
+ + + +
+

+ Trip Information +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

+ Days Calculation +

+ +
+
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+

+ Provided Meals (Deductions) +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

+ Association +

+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+ + +
+ + + {% if not per_diem %} +
+
+ + +
+
+ {% endif %} + + +
+ + Cancel + + + +
+
+
+ +{% endblock %} + diff --git a/app/templates/per_diem/list.html b/app/templates/per_diem/list.html new file mode 100644 index 0000000..cdcd00c --- /dev/null +++ b/app/templates/per_diem/list.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Per Diem'} +] %} + +{{ page_header( + icon_class='fas fa-money-bill-alt', + title_text='Per Diem Claims', + subtitle_text='Manage daily allowance claims', + breadcrumbs=breadcrumbs, + actions_html='' +) }} + + +
+
+
+
+

Total Claims

+

{{ pagination.total if pagination else (per_diem_claims|length) }}

+
+
+ +
+
+
+ +
+
+
+

Total Amount

+

€{{ '%.2f'|format(total_amount) }}

+
+
+ +
+
+
+
+ + +
+

Filter Claims

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+
+ + +
+
+ + + + + + + + + + + + + + {% if per_diem_claims %} + {% for claim in per_diem_claims %} + + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
PeriodPurposeLocationDaysAmountStatusActions
+ {{ claim.start_date.strftime('%Y-%m-%d') }}
+ to {{ claim.end_date.strftime('%Y-%m-%d') }} +
+ + {{ claim.trip_purpose }} + + {% if claim.project %} +
{{ claim.project.name }}
+ {% endif %} +
+ {{ claim.city + ', ' if claim.city else '' }}{{ claim.country }} + + {{ claim.total_days if claim.total_days else ((claim.full_days or 0) + (claim.half_days or 0) * 0.5) }} + + {{ claim.currency_code or 'EUR' }} {{ '%.2f'|format(claim.calculated_amount or 0) }} + + {% if claim.status == 'pending' %} + + Pending + + {% elif claim.status == 'approved' %} + + Approved + + {% elif claim.status == 'rejected' %} + + Rejected + + {% elif claim.status == 'reimbursed' %} + + Reimbursed + + {% endif %} + +
+ + + + {% if current_user.is_admin or claim.user_id == current_user.id %} + + + + {% endif %} +
+
+ +

No per diem claims found

+ + Create your first claim + +
+
+
+ +{% endblock %} + diff --git a/app/templates/per_diem/rate_form.html b/app/templates/per_diem/rate_form.html new file mode 100644 index 0000000..fc07633 --- /dev/null +++ b/app/templates/per_diem/rate_form.html @@ -0,0 +1,175 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')}, + {'text': 'Rates', 'url': url_for('per_diem.list_rates')}, + {'text': 'Edit' if rate else 'New'} +] %} + +{{ page_header( + icon_class='fas fa-list-alt', + title_text=('Edit Per Diem Rate' if rate else 'New Per Diem Rate'), + subtitle_text=('Update rate details' if rate else 'Create a new per diem rate'), + breadcrumbs=breadcrumbs +) %} + +
+
+ + + +
+

+ Location +

+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+

+ Rates +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

+ Effective Period +

+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+ + +
+ + +
+ + Cancel + + + +
+
+
+ +{% endblock %} + diff --git a/app/templates/per_diem/rates_list.html b/app/templates/per_diem/rates_list.html new file mode 100644 index 0000000..7839e4c --- /dev/null +++ b/app/templates/per_diem/rates_list.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')}, + {'text': 'Rates'} +] %} + +{{ page_header( + icon_class='fas fa-list-alt', + title_text='Per Diem Rates', + subtitle_text='Manage per diem rates by location', + breadcrumbs=breadcrumbs, + actions_html='New Rate' +) }} + + +
+
+ + + + + + + + + + + + + {% if rates %} + {% for rate in rates %} + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
LocationFull Day RateHalf Day RateCurrencyEffective FromStatus
+
{{ rate.city + ', ' if rate.city else '' }}{{ rate.country }}
+
+ {{ '%.2f'|format(rate.full_day_rate) }} + + {{ '%.2f'|format(rate.half_day_rate) }} + + {{ rate.currency_code }} + + {{ rate.effective_from.strftime('%Y-%m-%d') }} + {% if rate.effective_to %} +
to {{ rate.effective_to.strftime('%Y-%m-%d') }} + {% endif %} +
+ {% if rate.is_active %} + + Active + + {% else %} + + Inactive + + {% endif %} +
+ +

No per diem rates found

+ + Create your first rate + +
+
+
+ +{% endblock %} + diff --git a/app/templates/per_diem/view.html b/app/templates/per_diem/view.html new file mode 100644 index 0000000..495d08b --- /dev/null +++ b/app/templates/per_diem/view.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')}, + {'text': 'Claim #' + per_diem.id|string} +] %} + +{{ page_header( + icon_class='fas fa-money-bill-alt', + title_text='Per Diem Claim #' + per_diem.id|string, + subtitle_text=per_diem.trip_purpose, + breadcrumbs=breadcrumbs, + actions_html='Edit' if current_user.is_admin or per_diem.user_id == current_user.id else '' +) }} + +
+ +
+ +
+

+ Claim Details +

+ +
+
+

Period

+

{{ per_diem.start_date.strftime('%Y-%m-%d') }} to {{ per_diem.end_date.strftime('%Y-%m-%d') }}

+
+ +
+

Location

+

{{ per_diem.city + ', ' if per_diem.city else '' }}{{ per_diem.country }}

+
+ +
+

Full Days

+

{{ per_diem.full_days }}

+
+ +
+

Half Days

+

{{ per_diem.half_days }}

+
+ +
+
+

Total Amount

+

{{ per_diem.currency_code or 'EUR' }} {{ '%.2f'|format(per_diem.calculated_amount or 0) }}

+
+
+
+
+
+ + +
+ +
+

+ Status +

+ +
+ {% if per_diem.status == 'pending' %} + + Pending + + {% elif per_diem.status == 'approved' %} + + Approved + + {% elif per_diem.status == 'rejected' %} + + Rejected + + {% elif per_diem.status == 'reimbursed' %} + + Reimbursed + + {% endif %} +
+
+ + + {% if current_user.is_admin and per_diem.status == 'pending' %} +
+

+ Admin Actions +

+ +
+ + +
+ +
+ + + +
+
+ {% endif %} +
+
+ +{% endblock %} + diff --git a/app/utils/ocr.py b/app/utils/ocr.py new file mode 100644 index 0000000..115b6c4 --- /dev/null +++ b/app/utils/ocr.py @@ -0,0 +1,344 @@ +""" +OCR utilities for receipt scanning and text extraction. + +This module provides functionality to extract text and data from receipt images +using Tesseract OCR and parse common receipt information. +""" + +import os +import re +from decimal import Decimal +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + +# Check if Tesseract is available +try: + import pytesseract + from PIL import Image + TESSERACT_AVAILABLE = True +except ImportError: + TESSERACT_AVAILABLE = False + logger.warning("pytesseract or PIL not installed. Receipt OCR will not be available.") + + +def is_ocr_available(): + """Check if OCR functionality is available""" + return TESSERACT_AVAILABLE + + +def extract_text_from_image(image_path, lang='eng'): + """ + Extract text from an image using Tesseract OCR. + + Args: + image_path: Path to the image file + lang: OCR language (default: 'eng', can be 'eng+deu' for multilingual) + + Returns: + Extracted text as string + """ + if not TESSERACT_AVAILABLE: + raise RuntimeError("Tesseract OCR is not available. Install pytesseract and PIL.") + + try: + # Open and preprocess image + image = Image.open(image_path) + + # Convert to RGB if necessary + if image.mode != 'RGB': + image = image.convert('RGB') + + # Extract text + text = pytesseract.image_to_string(image, lang=lang) + + return text + except Exception as e: + logger.error(f"Error extracting text from image {image_path}: {e}") + raise + + +def parse_receipt_data(text): + """ + Parse common receipt information from extracted text. + + Args: + text: Extracted text from receipt + + Returns: + Dictionary with parsed data (vendor, date, total, items, etc.) + """ + data = { + 'vendor': None, + 'date': None, + 'total': None, + 'tax': None, + 'subtotal': None, + 'items': [], + 'currency': 'EUR', + 'raw_text': text + } + + lines = text.split('\n') + + # Try to extract vendor (usually first few lines) + vendor_lines = [] + for line in lines[:5]: + line = line.strip() + if line and len(line) > 3: + vendor_lines.append(line) + + if vendor_lines: + data['vendor'] = vendor_lines[0] + + # Extract amounts + amounts = extract_amounts(text) + if amounts: + # Try to identify total (usually largest amount or labeled as total) + total_candidates = [] + + for amount_info in amounts: + label = amount_info.get('label', '').lower() + if any(keyword in label for keyword in ['total', 'gesamt', 'suma', 'totale']): + data['total'] = amount_info['amount'] + elif any(keyword in label for keyword in ['tax', 'vat', 'mwst', 'iva', 'tva']): + data['tax'] = amount_info['amount'] + elif any(keyword in label for keyword in ['subtotal', 'zwischensumme', 'sous-total']): + data['subtotal'] = amount_info['amount'] + else: + total_candidates.append(amount_info['amount']) + + # If no labeled total found, use the largest amount + if not data['total'] and total_candidates: + data['total'] = max(total_candidates) + + # Extract date + date = extract_date(text) + if date: + data['date'] = date + + # Extract currency + currency = extract_currency(text) + if currency: + data['currency'] = currency + + return data + + +def extract_amounts(text): + """ + Extract monetary amounts from text. + + Returns: + List of dictionaries with 'amount' and 'label' keys + """ + amounts = [] + + # Patterns for amounts (supports various formats) + # Examples: 12.34, 12,34, $12.34, €12,34, 12.34 EUR + patterns = [ + r'([A-Za-z\s]*?)\s*([$€£¥]?)\s*(\d{1,3}(?:[.,]\d{3})*[.,]\d{2})\s*([A-Z]{3})?', + ] + + for pattern in patterns: + matches = re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE) + for match in matches: + label = match.group(1).strip() if match.group(1) else '' + symbol = match.group(2) if match.group(2) else '' + amount_str = match.group(3) + currency = match.group(4) if match.group(4) else '' + + # Normalize amount (convert comma to dot if needed) + # Determine if comma or dot is decimal separator + if ',' in amount_str and '.' in amount_str: + # Has both, assume European format (1.234,56) + amount_str = amount_str.replace('.', '').replace(',', '.') + elif ',' in amount_str: + # Only comma, check if it's thousands separator or decimal + parts = amount_str.split(',') + if len(parts) == 2 and len(parts[1]) == 2: + # Likely decimal separator + amount_str = amount_str.replace(',', '.') + else: + # Likely thousands separator + amount_str = amount_str.replace(',', '') + + try: + amount = Decimal(amount_str) + amounts.append({ + 'amount': amount, + 'label': label, + 'symbol': symbol, + 'currency': currency + }) + except (ValueError, Decimal.InvalidOperation): + continue + + return amounts + + +def extract_date(text): + """ + Extract date from receipt text. + + Returns: + datetime.date object or None + """ + # Common date patterns + patterns = [ + r'(\d{1,2})[./\-](\d{1,2})[./\-](\d{2,4})', # DD/MM/YYYY or MM/DD/YYYY + r'(\d{4})[./\-](\d{1,2})[./\-](\d{1,2})', # YYYY-MM-DD + r'(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{2,4})', # DD Month YYYY + ] + + for pattern in patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + try: + groups = match.groups() + + if len(groups) == 3: + if pattern == patterns[0]: # DD/MM/YYYY or MM/DD/YYYY + # Try DD/MM/YYYY first (European format) + try: + day, month, year = int(groups[0]), int(groups[1]), int(groups[2]) + if year < 100: + year += 2000 + return datetime(year, month, day).date() + except ValueError: + # Try MM/DD/YYYY (US format) + try: + month, day, year = int(groups[0]), int(groups[1]), int(groups[2]) + if year < 100: + year += 2000 + return datetime(year, month, day).date() + except ValueError: + continue + + elif pattern == patterns[1]: # YYYY-MM-DD + year, month, day = int(groups[0]), int(groups[1]), int(groups[2]) + return datetime(year, month, day).date() + + elif pattern == patterns[2]: # DD Month YYYY + day = int(groups[0]) + month_str = groups[1].lower() + year = int(groups[2]) + if year < 100: + year += 2000 + + months = { + 'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, + 'may': 5, 'jun': 6, 'jul': 7, 'aug': 8, + 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12 + } + month = months.get(month_str[:3]) + if month: + return datetime(year, month, day).date() + + except (ValueError, TypeError): + continue + + return None + + +def extract_currency(text): + """ + Extract currency code from receipt text. + + Returns: + 3-letter currency code (ISO 4217) or 'EUR' as default + """ + # Currency symbols and their codes + currency_symbols = { + '$': 'USD', + '€': 'EUR', + '£': 'GBP', + '¥': 'JPY', + '₹': 'INR', + 'Fr': 'CHF' + } + + # Look for currency symbols + for symbol, code in currency_symbols.items(): + if symbol in text: + return code + + # Look for currency codes (3 uppercase letters) + currency_pattern = r'\b([A-Z]{3})\b' + matches = re.findall(currency_pattern, text) + + # Common currency codes + common_currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'INR'] + + for match in matches: + if match in common_currencies: + return match + + return 'EUR' # Default + + +def scan_receipt(image_path, lang='eng'): + """ + Scan a receipt image and extract structured data. + + Args: + image_path: Path to the receipt image + lang: OCR language(s) to use (e.g., 'eng', 'eng+deu') + + Returns: + Dictionary with extracted receipt data + """ + if not is_ocr_available(): + return { + 'error': 'OCR not available', + 'message': 'Please install pytesseract and Pillow: pip install pytesseract pillow' + } + + try: + # Extract text + text = extract_text_from_image(image_path, lang=lang) + + # Parse data + data = parse_receipt_data(text) + + return data + + except Exception as e: + logger.error(f"Error scanning receipt {image_path}: {e}") + return { + 'error': str(e), + 'message': 'Failed to scan receipt' + } + + +def get_suggested_expense_data(receipt_data): + """ + Convert receipt data to expense form data suggestions. + + Args: + receipt_data: Dictionary returned by scan_receipt() + + Returns: + Dictionary with suggested expense data + """ + suggestions = {} + + if receipt_data.get('vendor'): + suggestions['vendor'] = receipt_data['vendor'] + suggestions['title'] = f"Receipt from {receipt_data['vendor']}" + + if receipt_data.get('total'): + suggestions['amount'] = float(receipt_data['total']) + + if receipt_data.get('tax'): + suggestions['tax_amount'] = float(receipt_data['tax']) + + if receipt_data.get('date'): + suggestions['expense_date'] = receipt_data['date'].isoformat() + + if receipt_data.get('currency'): + suggestions['currency_code'] = receipt_data['currency'] + + return suggestions + diff --git a/fix_expense_schema.sql b/fix_expense_schema.sql new file mode 100644 index 0000000..0e885df --- /dev/null +++ b/fix_expense_schema.sql @@ -0,0 +1,77 @@ +-- Fix Advanced Expense Management Schema +-- Run this manually to fix the column name mismatches + +-- Fix mileage table +ALTER TABLE mileage RENAME COLUMN trip_purpose TO purpose; +ALTER TABLE mileage RENAME COLUMN vehicle_registration TO license_plate; +ALTER TABLE mileage RENAME COLUMN total_amount TO calculated_amount; + +-- Add missing columns to mileage +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS start_odometer NUMERIC(10, 2); +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS end_odometer NUMERIC(10, 2); +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS distance_miles NUMERIC(10, 2); +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS rate_per_mile NUMERIC(10, 4); +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(200); +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS is_round_trip BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS reimbursed BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS reimbursed_at TIMESTAMP; +ALTER TABLE mileage ADD COLUMN IF NOT EXISTS currency_code VARCHAR(3) NOT NULL DEFAULT 'EUR'; + +-- Make rate_per_km NOT NULL +ALTER TABLE mileage ALTER COLUMN rate_per_km SET NOT NULL; +ALTER TABLE mileage ALTER COLUMN rate_per_km SET DEFAULT 0.30; + +-- Fix per_diem_rates table +ALTER TABLE per_diem_rates RENAME COLUMN location TO city; +ALTER TABLE per_diem_rates RENAME COLUMN valid_from TO effective_from; +ALTER TABLE per_diem_rates RENAME COLUMN valid_to TO effective_to; +ALTER TABLE per_diem_rates RENAME COLUMN country_code TO country; + +-- Add missing columns to per_diem_rates +ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS full_day_rate NUMERIC(10, 2) NOT NULL DEFAULT 0; +ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS half_day_rate NUMERIC(10, 2) NOT NULL DEFAULT 0; +ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS breakfast_rate NUMERIC(10, 2); +ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS lunch_rate NUMERIC(10, 2); +ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS dinner_rate NUMERIC(10, 2); +ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS incidental_rate NUMERIC(10, 2); +ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- Copy rate_per_day to full_day_rate and calculate half_day_rate +UPDATE per_diem_rates SET full_day_rate = rate_per_day, half_day_rate = rate_per_day * 0.5 WHERE full_day_rate = 0; + +-- Drop old columns from per_diem_rates +ALTER TABLE per_diem_rates DROP COLUMN IF EXISTS rate_per_day; +ALTER TABLE per_diem_rates DROP COLUMN IF EXISTS breakfast_deduction; +ALTER TABLE per_diem_rates DROP COLUMN IF EXISTS lunch_deduction; +ALTER TABLE per_diem_rates DROP COLUMN IF EXISTS dinner_deduction; + +-- Fix per_diems table +ALTER TABLE per_diems RENAME COLUMN trip_start_date TO start_date; +ALTER TABLE per_diems RENAME COLUMN trip_end_date TO end_date; +ALTER TABLE per_diems RENAME COLUMN destination_country TO country; +ALTER TABLE per_diems RENAME COLUMN destination_location TO city; +ALTER TABLE per_diems RENAME COLUMN number_of_days TO full_days; +ALTER TABLE per_diems RENAME COLUMN total_amount TO calculated_amount; + +-- Add missing columns to per_diems +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS trip_purpose VARCHAR(255) NOT NULL DEFAULT 'Business trip'; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS departure_time TIME; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS return_time TIME; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS half_days INTEGER NOT NULL DEFAULT 0; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS total_days NUMERIC(5, 2) NOT NULL DEFAULT 0; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS full_day_rate NUMERIC(10, 2) NOT NULL DEFAULT 0; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS half_day_rate NUMERIC(10, 2) NOT NULL DEFAULT 0; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS breakfast_deduction NUMERIC(10, 2) NOT NULL DEFAULT 0; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS lunch_deduction NUMERIC(10, 2) NOT NULL DEFAULT 0; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS dinner_deduction NUMERIC(10, 2) NOT NULL DEFAULT 0; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS reimbursed BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS reimbursed_at TIMESTAMP; +ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS approval_notes TEXT; + +-- Mark migration as applied (optional) +-- UPDATE alembic_version SET version_num = '038_fix_expenses_schema'; + +SELECT 'Schema fixed successfully!' AS result; + diff --git a/migrations/versions/037_advanced_expenses.py b/migrations/versions/037_advanced_expenses.py new file mode 100644 index 0000000..2bb19ae --- /dev/null +++ b/migrations/versions/037_advanced_expenses.py @@ -0,0 +1,204 @@ +"""Add advanced expense management + +Revision ID: 037_advanced_expenses +Revises: 036_add_pdf_design_json +Create Date: 2025-10-30 14:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '037_advanced_expenses' +down_revision = '036_add_pdf_design_json' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create expense_categories table + op.create_table( + 'expense_categories', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('code', sa.String(length=20), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True), + sa.Column('monthly_budget', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('quarterly_budget', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('yearly_budget', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('budget_threshold_percent', sa.Integer(), nullable=False, server_default='80'), + sa.Column('requires_receipt', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('requires_approval', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('default_tax_rate', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + sa.UniqueConstraint('code') + ) + op.create_index('ix_expense_categories_name', 'expense_categories', ['name'], unique=True) + op.create_index('ix_expense_categories_code', 'expense_categories', ['code'], unique=True) + + # Create mileage table (without expense_id FK initially) + op.create_table( + 'mileage', + sa.Column('id', sa.Integer(), nullable=False), + 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('expense_id', sa.Integer(), nullable=True), + sa.Column('trip_date', sa.Date(), nullable=False), + sa.Column('trip_purpose', sa.Text(), nullable=False), + sa.Column('start_location', sa.String(length=255), nullable=False), + sa.Column('end_location', sa.String(length=255), nullable=False), + sa.Column('distance_km', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('vehicle_type', sa.String(length=50), nullable=True), + sa.Column('vehicle_registration', sa.String(length=20), nullable=True), + sa.Column('rate_per_km', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('total_amount', sa.Numeric(precision=10, scale=2), 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('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['users.id']), + sa.ForeignKeyConstraint(['project_id'], ['projects.id']), + sa.ForeignKeyConstraint(['client_id'], ['clients.id']), + sa.ForeignKeyConstraint(['approved_by'], ['users.id']) + ) + op.create_index('ix_mileage_user_id', 'mileage', ['user_id']) + op.create_index('ix_mileage_trip_date', 'mileage', ['trip_date']) + + # Create per_diem_rates table + op.create_table( + 'per_diem_rates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('country_code', sa.String(length=2), nullable=False), + sa.Column('location', sa.String(length=255), nullable=True), + sa.Column('rate_per_day', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('breakfast_deduction', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('lunch_deduction', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('dinner_deduction', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('valid_from', sa.Date(), nullable=False), + sa.Column('valid_to', sa.Date(), nullable=True), + sa.Column('currency_code', sa.String(length=3), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_per_diem_rates_country', 'per_diem_rates', ['country_code']) + op.create_index('ix_per_diem_rates_valid_from', 'per_diem_rates', ['valid_from']) + + # Create per_diems table (without expense_id FK initially) + op.create_table( + 'per_diems', + sa.Column('id', sa.Integer(), nullable=False), + 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('expense_id', sa.Integer(), nullable=True), + sa.Column('trip_start_date', sa.Date(), nullable=False), + sa.Column('trip_end_date', sa.Date(), nullable=False), + sa.Column('destination_country', sa.String(length=2), nullable=False), + sa.Column('destination_location', sa.String(length=255), nullable=True), + sa.Column('per_diem_rate_id', sa.Integer(), nullable=True), + sa.Column('number_of_days', sa.Integer(), nullable=False), + sa.Column('breakfast_provided', sa.Integer(), nullable=False, server_default='0'), + sa.Column('lunch_provided', sa.Integer(), nullable=False, server_default='0'), + sa.Column('dinner_provided', sa.Integer(), nullable=False, server_default='0'), + sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('currency_code', sa.String(length=3), nullable=False), + 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('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['users.id']), + sa.ForeignKeyConstraint(['project_id'], ['projects.id']), + sa.ForeignKeyConstraint(['client_id'], ['clients.id']), + sa.ForeignKeyConstraint(['per_diem_rate_id'], ['per_diem_rates.id']), + sa.ForeignKeyConstraint(['approved_by'], ['users.id']) + ) + op.create_index('ix_per_diems_user_id', 'per_diems', ['user_id']) + op.create_index('ix_per_diems_trip_start', 'per_diems', ['trip_start_date']) + + # Add new columns to expenses table + op.add_column('expenses', sa.Column('ocr_data', sa.Text(), nullable=True)) + op.add_column('expenses', sa.Column('mileage_id', sa.Integer(), nullable=True)) + op.add_column('expenses', sa.Column('per_diem_id', sa.Integer(), nullable=True)) + + # Add foreign keys from expenses to mileage and per_diems + op.create_foreign_key('fk_expenses_mileage', 'expenses', 'mileage', ['mileage_id'], ['id']) + op.create_foreign_key('fk_expenses_per_diem', 'expenses', 'per_diems', ['per_diem_id'], ['id']) + + # Now add the circular foreign keys from mileage and per_diems back to expenses + op.create_foreign_key('fk_mileage_expense', 'mileage', 'expenses', ['expense_id'], ['id']) + op.create_foreign_key('fk_per_diems_expense', 'per_diems', 'expenses', ['expense_id'], ['id']) + + # Insert default expense categories + op.execute(""" + INSERT INTO expense_categories (name, code, color, icon, requires_receipt, requires_approval, is_active) + VALUES + ('Travel', 'TRAVEL', '#4CAF50', '✈️', true, true, true), + ('Meals', 'MEALS', '#FF9800', '🍽️', true, false, true), + ('Accommodation', 'ACCOM', '#2196F3', '🏨', true, true, true), + ('Office Supplies', 'OFFICE', '#9C27B0', '📎', false, false, true), + ('Equipment', 'EQUIP', '#F44336', '💻', true, true, true), + ('Mileage', 'MILE', '#00BCD4', '🚗', false, false, true), + ('Per Diem', 'PERDIEM', '#8BC34A', '📅', false, false, true) + ON CONFLICT (name) DO NOTHING + """) + + # Insert default per diem rates + op.execute(""" + INSERT INTO per_diem_rates (country_code, location, rate_per_day, breakfast_deduction, lunch_deduction, dinner_deduction, valid_from, currency_code, is_active) + VALUES + ('US', 'General', 55.00, 13.00, 16.00, 26.00, '2025-01-01', 'USD', true), + ('GB', 'General', 45.00, 10.00, 13.00, 22.00, '2025-01-01', 'GBP', true), + ('DE', 'General', 24.00, 5.00, 8.00, 11.00, '2025-01-01', 'EUR', true), + ('FR', 'General', 20.00, 4.00, 7.00, 9.00, '2025-01-01', 'EUR', true) + """) + + +def downgrade(): + # Remove circular foreign keys first + op.drop_constraint('fk_per_diems_expense', 'per_diems', type_='foreignkey') + op.drop_constraint('fk_mileage_expense', 'mileage', type_='foreignkey') + + # Remove foreign keys from expenses + op.drop_constraint('fk_expenses_per_diem', 'expenses', type_='foreignkey') + op.drop_constraint('fk_expenses_mileage', 'expenses', type_='foreignkey') + + # Remove columns from expenses table + op.drop_column('expenses', 'per_diem_id') + op.drop_column('expenses', 'mileage_id') + op.drop_column('expenses', 'ocr_data') + + # Drop tables in reverse order + op.drop_index('ix_per_diems_trip_start', table_name='per_diems') + op.drop_index('ix_per_diems_user_id', table_name='per_diems') + op.drop_table('per_diems') + + op.drop_index('ix_per_diem_rates_valid_from', table_name='per_diem_rates') + op.drop_index('ix_per_diem_rates_country', table_name='per_diem_rates') + op.drop_table('per_diem_rates') + + op.drop_index('ix_mileage_trip_date', table_name='mileage') + op.drop_index('ix_mileage_user_id', table_name='mileage') + op.drop_table('mileage') + + op.drop_index('ix_expense_categories_code', table_name='expense_categories') + op.drop_index('ix_expense_categories_name', table_name='expense_categories') + op.drop_table('expense_categories') + diff --git a/migrations/versions/038_fix_advanced_expenses_schema.py b/migrations/versions/038_fix_advanced_expenses_schema.py new file mode 100644 index 0000000..ef468e6 --- /dev/null +++ b/migrations/versions/038_fix_advanced_expenses_schema.py @@ -0,0 +1,147 @@ +"""Fix advanced expenses schema + +Revision ID: 038_fix_expenses_schema +Revises: 037_advanced_expenses +Create Date: 2025-10-30 15:05:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '038_fix_expenses_schema' +down_revision = '037_advanced_expenses' +branch_labels = None +depends_on = None + + +def upgrade(): + # Fix mileage table - rename columns and add missing ones + op.alter_column('mileage', 'trip_purpose', new_column_name='purpose', existing_type=sa.Text(), existing_nullable=False) + op.alter_column('mileage', 'vehicle_registration', new_column_name='license_plate', existing_type=sa.String(20), existing_nullable=True) + op.alter_column('mileage', 'total_amount', new_column_name='calculated_amount', existing_type=sa.Numeric(10, 2), existing_nullable=True) + + # Add missing columns to mileage + op.add_column('mileage', sa.Column('description', sa.Text(), nullable=True)) + op.add_column('mileage', sa.Column('start_odometer', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('mileage', sa.Column('end_odometer', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('mileage', sa.Column('distance_miles', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('mileage', sa.Column('rate_per_mile', sa.Numeric(precision=10, scale=4), nullable=True)) + op.add_column('mileage', sa.Column('vehicle_description', sa.String(length=200), nullable=True)) + op.add_column('mileage', sa.Column('is_round_trip', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('mileage', sa.Column('reimbursed', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('mileage', sa.Column('reimbursed_at', sa.DateTime(), nullable=True)) + op.add_column('mileage', sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR')) + + # Make rate_per_km NOT NULL (it's required) + op.alter_column('mileage', 'rate_per_km', nullable=False, server_default='0.30') + + # Fix per_diem_rates table - rename columns + op.alter_column('per_diem_rates', 'location', new_column_name='city', existing_type=sa.String(255), existing_nullable=True) + op.alter_column('per_diem_rates', 'valid_from', new_column_name='effective_from', existing_type=sa.Date(), existing_nullable=False) + op.alter_column('per_diem_rates', 'valid_to', new_column_name='effective_to', existing_type=sa.Date(), existing_nullable=True) + + # Add missing columns to per_diem_rates + op.add_column('per_diem_rates', sa.Column('full_day_rate', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0')) + op.add_column('per_diem_rates', sa.Column('half_day_rate', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0')) + op.add_column('per_diem_rates', sa.Column('breakfast_rate', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('per_diem_rates', sa.Column('lunch_rate', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('per_diem_rates', sa.Column('dinner_rate', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('per_diem_rates', sa.Column('incidental_rate', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('per_diem_rates', sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'))) + + # Rename country_code to country + op.alter_column('per_diem_rates', 'country_code', new_column_name='country', existing_type=sa.String(2), existing_nullable=False) + + # Drop old rate_per_day column after copying to full_day_rate + op.execute("UPDATE per_diem_rates SET full_day_rate = rate_per_day, half_day_rate = rate_per_day * 0.5") + op.drop_column('per_diem_rates', 'rate_per_day') + op.drop_column('per_diem_rates', 'breakfast_deduction') + op.drop_column('per_diem_rates', 'lunch_deduction') + op.drop_column('per_diem_rates', 'dinner_deduction') + + # Fix per_diems table - rename columns + op.alter_column('per_diems', 'trip_start_date', new_column_name='start_date', existing_type=sa.Date(), existing_nullable=False) + op.alter_column('per_diems', 'trip_end_date', new_column_name='end_date', existing_type=sa.Date(), existing_nullable=False) + op.alter_column('per_diems', 'destination_country', new_column_name='country', existing_type=sa.String(2), existing_nullable=False) + op.alter_column('per_diems', 'destination_location', new_column_name='city', existing_type=sa.String(255), existing_nullable=True) + op.alter_column('per_diems', 'number_of_days', new_column_name='full_days', existing_type=sa.Integer(), existing_nullable=False) + op.alter_column('per_diems', 'total_amount', new_column_name='calculated_amount', existing_type=sa.Numeric(10, 2), existing_nullable=True) + + # Add missing columns to per_diems + op.add_column('per_diems', sa.Column('trip_purpose', sa.String(length=255), nullable=False, server_default='Business trip')) + op.add_column('per_diems', sa.Column('description', sa.Text(), nullable=True)) + op.add_column('per_diems', sa.Column('departure_time', sa.Time(), nullable=True)) + op.add_column('per_diems', sa.Column('return_time', sa.Time(), nullable=True)) + op.add_column('per_diems', sa.Column('half_days', sa.Integer(), nullable=False, server_default='0')) + op.add_column('per_diems', sa.Column('total_days', sa.Numeric(precision=5, scale=2), nullable=False, server_default='0')) + op.add_column('per_diems', sa.Column('full_day_rate', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0')) + op.add_column('per_diems', sa.Column('half_day_rate', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0')) + op.add_column('per_diems', sa.Column('breakfast_deduction', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0')) + op.add_column('per_diems', sa.Column('lunch_deduction', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0')) + op.add_column('per_diems', sa.Column('dinner_deduction', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0')) + op.add_column('per_diems', sa.Column('reimbursed', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('per_diems', sa.Column('reimbursed_at', sa.DateTime(), nullable=True)) + op.add_column('per_diems', sa.Column('approval_notes', sa.Text(), nullable=True)) + + +def downgrade(): + # Revert per_diems changes + op.drop_column('per_diems', 'approval_notes') + op.drop_column('per_diems', 'reimbursed_at') + op.drop_column('per_diems', 'reimbursed') + op.drop_column('per_diems', 'dinner_deduction') + op.drop_column('per_diems', 'lunch_deduction') + op.drop_column('per_diems', 'breakfast_deduction') + op.drop_column('per_diems', 'half_day_rate') + op.drop_column('per_diems', 'full_day_rate') + op.drop_column('per_diems', 'total_days') + op.drop_column('per_diems', 'half_days') + op.drop_column('per_diems', 'return_time') + op.drop_column('per_diems', 'departure_time') + op.drop_column('per_diems', 'description') + op.drop_column('per_diems', 'trip_purpose') + + op.alter_column('per_diems', 'calculated_amount', new_column_name='total_amount') + op.alter_column('per_diems', 'full_days', new_column_name='number_of_days') + op.alter_column('per_diems', 'city', new_column_name='destination_location') + op.alter_column('per_diems', 'country', new_column_name='destination_country') + op.alter_column('per_diems', 'end_date', new_column_name='trip_end_date') + op.alter_column('per_diems', 'start_date', new_column_name='trip_start_date') + + # Revert per_diem_rates changes + op.add_column('per_diem_rates', sa.Column('rate_per_day', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0')) + op.add_column('per_diem_rates', sa.Column('breakfast_deduction', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('per_diem_rates', sa.Column('lunch_deduction', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('per_diem_rates', sa.Column('dinner_deduction', sa.Numeric(precision=10, scale=2), nullable=True)) + op.execute("UPDATE per_diem_rates SET rate_per_day = full_day_rate") + op.drop_column('per_diem_rates', 'updated_at') + op.drop_column('per_diem_rates', 'incidental_rate') + op.drop_column('per_diem_rates', 'dinner_rate') + op.drop_column('per_diem_rates', 'lunch_rate') + op.drop_column('per_diem_rates', 'breakfast_rate') + op.drop_column('per_diem_rates', 'half_day_rate') + op.drop_column('per_diem_rates', 'full_day_rate') + + op.alter_column('per_diem_rates', 'country', new_column_name='country_code') + op.alter_column('per_diem_rates', 'effective_to', new_column_name='valid_to') + op.alter_column('per_diem_rates', 'effective_from', new_column_name='valid_from') + op.alter_column('per_diem_rates', 'city', new_column_name='location') + + # Revert mileage changes + op.drop_column('mileage', 'currency_code') + op.drop_column('mileage', 'reimbursed_at') + op.drop_column('mileage', 'reimbursed') + op.drop_column('mileage', 'is_round_trip') + op.drop_column('mileage', 'vehicle_description') + op.drop_column('mileage', 'rate_per_mile') + op.drop_column('mileage', 'distance_miles') + op.drop_column('mileage', 'end_odometer') + op.drop_column('mileage', 'start_odometer') + op.drop_column('mileage', 'description') + + op.alter_column('mileage', 'rate_per_km', nullable=True) + op.alter_column('mileage', 'calculated_amount', new_column_name='total_amount') + op.alter_column('mileage', 'license_plate', new_column_name='vehicle_registration') + op.alter_column('mileage', 'purpose', new_column_name='trip_purpose') + diff --git a/requirements.txt b/requirements.txt index 0b49057..eb2278d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -70,4 +70,7 @@ posthog==3.1.0 # API Documentation flask-swagger-ui==5.21.0 apispec==6.3.0 -marshmallow==3.20.1 \ No newline at end of file +marshmallow==3.20.1 + +# OCR for receipt scanning +pytesseract==0.3.10 \ No newline at end of file diff --git a/temp_migration.sql b/temp_migration.sql new file mode 100644 index 0000000..047ed64 --- /dev/null +++ b/temp_migration.sql @@ -0,0 +1,3 @@ +-- Temporary migration to set up advanced expense management schema +UPDATE alembic_version SET version_num = '037_add_advanced_expense_management'; + diff --git a/tests/test_models/test_expense_category.py b/tests/test_models/test_expense_category.py new file mode 100644 index 0000000..190e027 --- /dev/null +++ b/tests/test_models/test_expense_category.py @@ -0,0 +1,219 @@ +""" +Tests for ExpenseCategory model +""" + +import pytest +from datetime import date, datetime, timedelta +from decimal import Decimal +from app import db +from app.models import ExpenseCategory, Expense, User + + +@pytest.fixture +def user(client): + """Create a test user""" + user = User(username='testuser', email='test@example.com') + user.set_password('password123') + db.session.add(user) + db.session.commit() + return user + + +@pytest.fixture +def category(client): + """Create a test expense category""" + category = ExpenseCategory( + name='Travel', + code='TRV', + monthly_budget=5000, + quarterly_budget=15000, + yearly_budget=60000, + budget_threshold_percent=80, + requires_receipt=True, + requires_approval=True + ) + db.session.add(category) + db.session.commit() + return category + + +def test_create_expense_category(client): + """Test creating an expense category""" + category = ExpenseCategory( + name='Meals', + code='MEL', + description='Meal expenses', + monthly_budget=1000, + requires_receipt=True + ) + db.session.add(category) + db.session.commit() + + assert category.id is not None + assert category.name == 'Meals' + assert category.code == 'MEL' + assert category.monthly_budget == Decimal('1000') + assert category.requires_receipt is True + assert category.is_active is True + + +def test_category_budget_utilization(client, category, user): + """Test budget utilization calculation""" + # Create some approved expenses in current month + today = date.today() + start_of_month = date(today.year, today.month, 1) + + expense1 = Expense( + user_id=user.id, + title='Flight tickets', + category='Travel', + amount=2000, + expense_date=today, + status='approved' + ) + expense2 = Expense( + user_id=user.id, + title='Hotel', + category='Travel', + amount=1500, + expense_date=today, + status='approved' + ) + + db.session.add_all([expense1, expense2]) + db.session.commit() + + # Get monthly utilization + util = category.get_budget_utilization('monthly') + + assert util is not None + assert util['budget'] == 5000 + assert util['spent'] == 3500 + assert util['utilization_percent'] == 70.0 + assert util['remaining'] == 1500 + assert util['over_threshold'] is False + + +def test_category_over_budget_threshold(client, category, user): + """Test detecting when budget threshold is exceeded""" + today = date.today() + + # Create expense that exceeds threshold (80% of 5000 = 4000) + expense = Expense( + user_id=user.id, + title='Expensive trip', + category='Travel', + amount=4500, + expense_date=today, + status='approved' + ) + + db.session.add(expense) + db.session.commit() + + # Get monthly utilization + util = category.get_budget_utilization('monthly') + + assert util is not None + assert util['utilization_percent'] == 90.0 + assert util['over_threshold'] is True + + +def test_get_active_categories(client, category): + """Test getting active categories""" + # Create an inactive category + inactive_category = ExpenseCategory( + name='Deprecated', + code='DEP', + is_active=False + ) + db.session.add(inactive_category) + db.session.commit() + + # Get active categories + active_categories = ExpenseCategory.get_active_categories() + + assert len(active_categories) >= 1 + assert category in active_categories + assert inactive_category not in active_categories + + +def test_category_to_dict(client, category): + """Test converting category to dictionary""" + data = category.to_dict() + + assert data['id'] == category.id + assert data['name'] == 'Travel' + assert data['code'] == 'TRV' + assert data['monthly_budget'] == 5000 + assert data['quarterly_budget'] == 15000 + assert data['yearly_budget'] == 60000 + assert data['budget_threshold_percent'] == 80 + assert data['requires_receipt'] is True + assert data['requires_approval'] is True + assert data['is_active'] is True + + +def test_category_unique_name(client, category): + """Test that category names must be unique""" + duplicate = ExpenseCategory( + name='Travel', # Same as existing category + code='TRV2' + ) + db.session.add(duplicate) + + with pytest.raises(Exception): # IntegrityError + db.session.commit() + + +def test_category_quarterly_budget(client, category, user): + """Test quarterly budget utilization""" + today = date.today() + quarter = (today.month - 1) // 3 + 1 + start_month = (quarter - 1) * 3 + 1 + + # Create expenses in current quarter + expense = Expense( + user_id=user.id, + title='Q1 Travel', + category='Travel', + amount=8000, + expense_date=today, + status='approved' + ) + + db.session.add(expense) + db.session.commit() + + # Get quarterly utilization + util = category.get_budget_utilization('quarterly') + + assert util is not None + assert util['budget'] == 15000 + assert util['spent'] == 8000 + assert util['utilization_percent'] == pytest.approx(53.33, rel=0.1) + + +def test_get_categories_over_budget(client, category, user): + """Test getting categories over budget threshold""" + today = date.today() + + # Create expense that exceeds threshold + expense = Expense( + user_id=user.id, + title='Over budget', + category='Travel', + amount=4500, + expense_date=today, + status='approved' + ) + + db.session.add(expense) + db.session.commit() + + # Get categories over budget + over_budget = ExpenseCategory.get_categories_over_budget('monthly') + + assert len(over_budget) > 0 + assert any(item['category'].id == category.id for item in over_budget) + diff --git a/tests/test_models/test_mileage.py b/tests/test_models/test_mileage.py new file mode 100644 index 0000000..69bd0e9 --- /dev/null +++ b/tests/test_models/test_mileage.py @@ -0,0 +1,276 @@ +""" +Tests for Mileage model +""" + +import pytest +from datetime import date, datetime +from decimal import Decimal +from app import db +from app.models import Mileage, User, Project, Client, Expense + + +@pytest.fixture +def user(client): + """Create a test user""" + user = User(username='testuser', email='test@example.com') + user.set_password('password123') + db.session.add(user) + db.session.commit() + return user + + +@pytest.fixture +def project(client): + """Create a test project""" + client_obj = Client(name='Test Client', company='Test Client') + db.session.add(client_obj) + db.session.commit() + + project = Project( + name='Test Project', + client_id=client_obj.id, + billable=True + ) + db.session.add(project) + db.session.commit() + return project + + +def test_create_mileage(client, user): + """Test creating a mileage entry""" + mileage = Mileage( + user_id=user.id, + trip_date=date.today(), + purpose='Client meeting', + start_location='Office', + end_location='Client Site', + distance_km=45.5, + rate_per_km=0.30, + vehicle_type='car' + ) + + db.session.add(mileage) + db.session.commit() + + assert mileage.id is not None + assert mileage.purpose == 'Client meeting' + assert mileage.distance_km == Decimal('45.5') + assert mileage.rate_per_km == Decimal('0.30') + assert mileage.calculated_amount == Decimal('13.65') + assert mileage.status == 'pending' + + +def test_mileage_round_trip(client, user): + """Test mileage calculation for round trip""" + mileage = Mileage( + user_id=user.id, + trip_date=date.today(), + purpose='Round trip', + start_location='A', + end_location='B', + distance_km=50, + rate_per_km=0.30, + is_round_trip=True + ) + + db.session.add(mileage) + db.session.commit() + + # Check that total distance and amount are doubled + assert mileage.total_distance_km == 100.0 + assert mileage.total_amount == 30.0 # 50 km * 2 * 0.30 + + +def test_mileage_approval(client, user): + """Test mileage approval workflow""" + admin = User(username='admin', email='admin@example.com', role='admin') + admin.set_password('admin123') + db.session.add(admin) + db.session.commit() + + mileage = Mileage( + user_id=user.id, + trip_date=date.today(), + purpose='Test trip', + start_location='A', + end_location='B', + distance_km=30, + rate_per_km=0.30 + ) + + db.session.add(mileage) + db.session.commit() + + # Approve mileage + mileage.approve(admin.id, notes='Approved') + db.session.commit() + + assert mileage.status == 'approved' + assert mileage.approved_by == admin.id + assert mileage.approved_at is not None + assert 'Approved' in mileage.notes + + +def test_mileage_rejection(client, user): + """Test mileage rejection workflow""" + admin = User(username='admin', email='admin@example.com', role='admin') + admin.set_password('admin123') + db.session.add(admin) + db.session.commit() + + mileage = Mileage( + user_id=user.id, + trip_date=date.today(), + purpose='Test trip', + start_location='A', + end_location='B', + distance_km=30, + rate_per_km=0.30 + ) + + db.session.add(mileage) + db.session.commit() + + # Reject mileage + mileage.reject(admin.id, reason='Missing documentation') + db.session.commit() + + assert mileage.status == 'rejected' + assert mileage.approved_by == admin.id + assert mileage.rejection_reason == 'Missing documentation' + + +def test_mileage_create_expense(client, user, project): + """Test creating expense from mileage entry""" + mileage = Mileage( + user_id=user.id, + trip_date=date.today(), + purpose='Client visit', + start_location='Office', + end_location='Client', + distance_km=40, + rate_per_km=0.30, + project_id=project.id, + is_round_trip=True + ) + + db.session.add(mileage) + db.session.commit() + + # Create expense + expense = mileage.create_expense() + + assert expense is not None + assert expense.user_id == user.id + assert expense.category == 'travel' + assert expense.amount == mileage.total_amount + assert expense.project_id == project.id + assert 'Distance' in expense.description + + +def test_mileage_to_dict(client, user): + """Test converting mileage to dictionary""" + mileage = Mileage( + user_id=user.id, + trip_date=date.today(), + purpose='Test trip', + start_location='A', + end_location='B', + distance_km=25.5, + rate_per_km=0.30 + ) + + db.session.add(mileage) + db.session.commit() + + data = mileage.to_dict() + + assert data['id'] == mileage.id + assert data['user_id'] == user.id + assert data['purpose'] == 'Test trip' + assert data['start_location'] == 'A' + assert data['end_location'] == 'B' + assert data['distance_km'] == 25.5 + assert data['rate_per_km'] == 0.30 + assert data['calculated_amount'] == 7.65 + assert data['status'] == 'pending' + + +def test_get_total_distance(client, user): + """Test getting total distance traveled""" + today = date.today() + + # Create multiple mileage entries + mileage1 = Mileage( + user_id=user.id, + trip_date=today, + purpose='Trip 1', + start_location='A', + end_location='B', + distance_km=30, + rate_per_km=0.30, + status='approved' + ) + + mileage2 = Mileage( + user_id=user.id, + trip_date=today, + purpose='Trip 2', + start_location='C', + end_location='D', + distance_km=50, + rate_per_km=0.30, + status='approved' + ) + + db.session.add_all([mileage1, mileage2]) + db.session.commit() + + # Get total distance + total = Mileage.get_total_distance(user_id=user.id) + + assert total == 80.0 + + +def test_mileage_default_rates(client): + """Test getting default mileage rates""" + rates = Mileage.get_default_rates() + + assert 'car' in rates + assert 'motorcycle' in rates + assert 'van' in rates + assert 'truck' in rates + + assert rates['car']['km'] == 0.30 + assert rates['motorcycle']['km'] == 0.20 + + +def test_mileage_reimbursement(client, user): + """Test marking mileage as reimbursed""" + admin = User(username='admin', email='admin@example.com', role='admin') + admin.set_password('admin123') + db.session.add(admin) + db.session.commit() + + mileage = Mileage( + user_id=user.id, + trip_date=date.today(), + purpose='Test trip', + start_location='A', + end_location='B', + distance_km=30, + rate_per_km=0.30, + status='approved' + ) + + db.session.add(mileage) + db.session.commit() + + # Mark as reimbursed + mileage.mark_as_reimbursed() + db.session.commit() + + assert mileage.status == 'reimbursed' + assert mileage.reimbursed is True + assert mileage.reimbursed_at is not None + diff --git a/tests/test_models/test_per_diem.py b/tests/test_models/test_per_diem.py new file mode 100644 index 0000000..2b09100 --- /dev/null +++ b/tests/test_models/test_per_diem.py @@ -0,0 +1,338 @@ +""" +Tests for PerDiem and PerDiemRate models +""" + +import pytest +from datetime import date, datetime, time +from decimal import Decimal +from app import db +from app.models import PerDiem, PerDiemRate, User + + +@pytest.fixture +def user(client): + """Create a test user""" + user = User(username='testuser', email='test@example.com') + user.set_password('password123') + db.session.add(user) + db.session.commit() + return user + + +@pytest.fixture +def rate(client): + """Create a test per diem rate""" + rate = PerDiemRate( + country='Germany', + city='Berlin', + full_day_rate=28.00, + half_day_rate=14.00, + breakfast_rate=5.60, + lunch_rate=11.20, + dinner_rate=11.20, + incidental_rate=3.00, + currency_code='EUR', + effective_from=date(2024, 1, 1) + ) + db.session.add(rate) + db.session.commit() + return rate + + +def test_create_per_diem_rate(client): + """Test creating a per diem rate""" + rate = PerDiemRate( + country='France', + city='Paris', + full_day_rate=45.00, + half_day_rate=22.50, + effective_from=date(2024, 1, 1) + ) + + db.session.add(rate) + db.session.commit() + + assert rate.id is not None + assert rate.country == 'France' + assert rate.city == 'Paris' + assert rate.full_day_rate == Decimal('45.00') + assert rate.half_day_rate == Decimal('22.50') + assert rate.is_active is True + + +def test_get_rate_for_location(client, rate): + """Test getting rate for a specific location""" + found_rate = PerDiemRate.get_rate_for_location('Germany', 'Berlin', date.today()) + + assert found_rate is not None + assert found_rate.id == rate.id + assert found_rate.city == 'Berlin' + + +def test_get_rate_falls_back_to_country(client): + """Test that rate search falls back to country rate if city not found""" + # Create country-level rate + country_rate = PerDiemRate( + country='Netherlands', + city=None, # Country-level rate + full_day_rate=35.00, + half_day_rate=17.50, + effective_from=date(2024, 1, 1) + ) + db.session.add(country_rate) + db.session.commit() + + # Search for a city that doesn't have a rate + found_rate = PerDiemRate.get_rate_for_location('Netherlands', 'Amsterdam', date.today()) + + assert found_rate is not None + assert found_rate.id == country_rate.id + assert found_rate.city is None + + +def test_create_per_diem_claim(client, user, rate): + """Test creating a per diem claim""" + per_diem = PerDiem( + user_id=user.id, + trip_purpose='Conference', + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 23), + country='Germany', + city='Berlin', + full_day_rate=rate.full_day_rate, + half_day_rate=rate.half_day_rate, + full_days=3, + half_days=1, + breakfast_deduction=rate.breakfast_rate, + currency_code='EUR' + ) + + db.session.add(per_diem) + db.session.commit() + + assert per_diem.id is not None + assert per_diem.trip_purpose == 'Conference' + assert per_diem.full_days == 3 + assert per_diem.half_days == 1 + assert per_diem.total_days == 3.5 + assert per_diem.status == 'pending' + + +def test_per_diem_calculation(client, user, rate): + """Test per diem amount calculation""" + per_diem = PerDiem( + user_id=user.id, + trip_purpose='Business trip', + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 22), + country='Germany', + city='Berlin', + full_day_rate=28, + half_day_rate=14, + full_days=2, + half_days=1, + breakfast_provided=0, + breakfast_deduction=0, + lunch_deduction=0, + dinner_deduction=0 + ) + + db.session.add(per_diem) + db.session.commit() + + # Calculation: (2 * 28) + (1 * 14) = 56 + 14 = 70 + assert per_diem.calculated_amount == Decimal('70') + + +def test_per_diem_with_meal_deductions(client, user, rate): + """Test per diem with provided meals""" + per_diem = PerDiem( + user_id=user.id, + trip_purpose='Conference with meals', + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 22), + country='Germany', + city='Berlin', + full_day_rate=28, + half_day_rate=14, + full_days=3, + half_days=0, + breakfast_provided=2, + lunch_provided=3, + dinner_provided=2, + breakfast_deduction=5.60, + lunch_deduction=11.20, + dinner_deduction=11.20 + ) + + db.session.add(per_diem) + db.session.commit() + + # Calculation: (3 * 28) - (2 * 5.60) - (3 * 11.20) - (2 * 11.20) + # = 84 - 11.20 - 33.60 - 22.40 = 16.80 + assert per_diem.calculated_amount == Decimal('16.80') + + +def test_calculate_days_from_dates_single_day(client): + """Test calculating days for a single day trip""" + result = PerDiem.calculate_days_from_dates( + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 20), + departure_time=time(8, 0), + return_time=time(18, 0) # 10 hours + ) + + assert result['full_days'] == 1 + assert result['half_days'] == 0 + + +def test_calculate_days_from_dates_multi_day(client): + """Test calculating days for multi-day trip""" + result = PerDiem.calculate_days_from_dates( + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 23), + departure_time=time(8, 0), # Before noon = full day + return_time=time(14, 0) # After noon = full day + ) + + # Day 1: departure before 12:00 = full day + # Day 2-3: middle days = 2 full days + # Day 4: return after 12:00 = full day + # Total: 4 full days + assert result['full_days'] == 4 + assert result['half_days'] == 0 + + +def test_calculate_days_with_half_days(client): + """Test calculating days with half days""" + result = PerDiem.calculate_days_from_dates( + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 22), + departure_time=time(14, 0), # After noon = half day + return_time=time(10, 0) # Before noon = half day + ) + + # Day 1: departure after 12:00 = half day + # Day 2: middle day = full day + # Day 3: return before 12:00 = half day + # Total: 1 full day, 2 half days + assert result['full_days'] == 1 + assert result['half_days'] == 2 + + +def test_per_diem_approval(client, user): + """Test per diem approval workflow""" + admin = User(username='admin', email='admin@example.com', role='admin') + admin.set_password('admin123') + db.session.add(admin) + db.session.commit() + + per_diem = PerDiem( + user_id=user.id, + trip_purpose='Business trip', + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 22), + country='Germany', + full_day_rate=28, + half_day_rate=14, + full_days=2, + half_days=1 + ) + + db.session.add(per_diem) + db.session.commit() + + # Approve + per_diem.approve(admin.id, notes='Approved') + db.session.commit() + + assert per_diem.status == 'approved' + assert per_diem.approved_by == admin.id + assert per_diem.approved_at is not None + + +def test_per_diem_to_dict(client, user, rate): + """Test converting per diem to dictionary""" + per_diem = PerDiem( + user_id=user.id, + trip_purpose='Test trip', + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 22), + country='Germany', + city='Berlin', + full_day_rate=28, + half_day_rate=14, + full_days=2, + half_days=1 + ) + + db.session.add(per_diem) + db.session.commit() + + data = per_diem.to_dict() + + assert data['id'] == per_diem.id + assert data['user_id'] == user.id + assert data['trip_purpose'] == 'Test trip' + assert data['country'] == 'Germany' + assert data['city'] == 'Berlin' + assert data['full_days'] == 2 + assert data['half_days'] == 1 + assert data['total_days'] == 2.5 + + +def test_per_diem_recalculate(client, user): + """Test recalculating per diem amount""" + per_diem = PerDiem( + user_id=user.id, + trip_purpose='Trip', + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 22), + country='Germany', + full_day_rate=28, + half_day_rate=14, + full_days=2, + half_days=0 + ) + + db.session.add(per_diem) + db.session.commit() + + initial_amount = per_diem.calculated_amount + assert initial_amount == Decimal('56') + + # Change days + per_diem.full_days = 3 + new_amount = per_diem.recalculate_amount() + + assert new_amount == Decimal('84') + assert per_diem.calculated_amount == Decimal('84') + + +def test_per_diem_create_expense(client, user): + """Test creating expense from per diem claim""" + per_diem = PerDiem( + user_id=user.id, + trip_purpose='Conference', + start_date=date(2025, 10, 20), + end_date=date(2025, 10, 23), + country='Germany', + city='Berlin', + full_day_rate=28, + half_day_rate=14, + full_days=3, + half_days=1 + ) + + db.session.add(per_diem) + db.session.commit() + + # Create expense + expense = per_diem.create_expense() + + assert expense is not None + assert expense.user_id == user.id + assert expense.category == 'meals' + assert expense.amount == per_diem.calculated_amount + assert 'Berlin, Germany' in expense.title +