diff --git a/app/__init__.py b/app/__init__.py index 1268337..3129183 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -756,6 +756,7 @@ def create_app(config=None): from app.routes.analytics import analytics_bp from app.routes.tasks import tasks_bp from app.routes.invoices import invoices_bp + from app.routes.payments import payments_bp from app.routes.clients import clients_bp from app.routes.client_notes import client_notes_bp from app.routes.comments import comments_bp @@ -783,6 +784,7 @@ def create_app(config=None): app.register_blueprint(analytics_bp) app.register_blueprint(tasks_bp) app.register_blueprint(invoices_bp) + app.register_blueprint(payments_bp) app.register_blueprint(clients_bp) app.register_blueprint(client_notes_bp) app.register_blueprint(comments_bp) diff --git a/app/models/payments.py b/app/models/payments.py index 9f0cb37..4d599fa 100644 --- a/app/models/payments.py +++ b/app/models/payments.py @@ -1,4 +1,5 @@ from datetime import datetime +from decimal import Decimal from app import db @@ -12,14 +13,53 @@ class Payment(db.Model): amount = db.Column(db.Numeric(10, 2), nullable=False) currency = db.Column(db.String(3), nullable=True) # If multi-currency per payment payment_date = db.Column(db.Date, nullable=False, default=datetime.utcnow) - method = db.Column(db.String(50), nullable=True) - reference = db.Column(db.String(100), nullable=True) + method = db.Column(db.String(50), nullable=True) # bank_transfer, cash, check, credit_card, paypal, stripe, etc. + reference = db.Column(db.String(100), nullable=True) # Transaction ID, check number, etc. notes = db.Column(db.Text, nullable=True) + status = db.Column(db.String(20), default='completed', nullable=False) # completed, pending, failed, refunded + + # Additional tracking fields + received_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # User who recorded the payment + gateway_transaction_id = db.Column(db.String(255), nullable=True) # For payment gateway transactions + gateway_fee = db.Column(db.Numeric(10, 2), nullable=True) # Transaction fees + net_amount = db.Column(db.Numeric(10, 2), nullable=True) # Amount after fees + + # 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 + receiver = db.relationship('User', backref='received_payments', foreign_keys=[received_by]) def __repr__(self): - return f"" + return f"" + + def calculate_net_amount(self): + """Calculate net amount after fees""" + if self.gateway_fee: + self.net_amount = self.amount - self.gateway_fee + else: + self.net_amount = self.amount + + def to_dict(self): + """Convert payment to dictionary for API responses""" + return { + 'id': self.id, + 'invoice_id': self.invoice_id, + 'amount': float(self.amount), + 'currency': self.currency, + 'payment_date': self.payment_date.isoformat() if self.payment_date else None, + 'method': self.method, + 'reference': self.reference, + 'notes': self.notes, + 'status': self.status, + 'received_by': self.received_by, + 'gateway_transaction_id': self.gateway_transaction_id, + 'gateway_fee': float(self.gateway_fee) if self.gateway_fee else None, + 'net_amount': float(self.net_amount) if self.net_amount else float(self.amount), + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } class CreditNote(db.Model): diff --git a/app/routes/analytics.py b/app/routes/analytics.py index d6a5c81..c2fe182 100644 --- a/app/routes/analytics.py +++ b/app/routes/analytics.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, jsonify from flask_login import login_required, current_user from app import db -from app.models import User, Project, TimeEntry, Settings, Task +from app.models import User, Project, TimeEntry, Settings, Task, Payment, Invoice from datetime import datetime, timedelta from sqlalchemy import func, extract, case import calendar @@ -564,6 +564,25 @@ def summary_with_comparison(): # Calculate billable percentage billable_percentage = round((current_billable / current_hours * 100), 1) if current_hours > 0 else 0 + # Get payment data for the period + payment_query = db.session.query( + func.sum(Payment.amount).label('total_payments'), + func.count(Payment.id).label('payment_count') + ).filter( + Payment.payment_date >= start_date, + Payment.payment_date <= end_date, + Payment.status == 'completed' + ) + + if not current_user.is_admin: + payment_query = payment_query.join(Invoice).join(Project).join(TimeEntry).filter( + TimeEntry.user_id == current_user.id + ) + + payment_result = payment_query.first() + total_payments = float(payment_result.total_payments or 0) + payment_count = payment_result.payment_count or 0 + return jsonify({ 'total_hours': current_hours, 'total_hours_change': round(hours_change, 1), @@ -573,7 +592,9 @@ def summary_with_comparison(): 'entries_change': round(entries_change, 1), 'active_projects': active_projects, 'avg_daily_hours': avg_daily_hours, - 'billable_percentage': billable_percentage + 'billable_percentage': billable_percentage, + 'total_payments': round(total_payments, 2), + 'payment_count': payment_count }) @@ -867,3 +888,318 @@ def insights(): return jsonify({ 'insights': insights_list }) + + +@analytics_bp.route('/api/analytics/payments-over-time') +@login_required +def payments_over_time(): + """Get payments over time""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + # Build query + query = db.session.query( + func.date(Payment.payment_date).label('date'), + func.sum(Payment.amount).label('total_amount'), + func.count(Payment.id).label('payment_count') + ).filter( + Payment.payment_date >= start_date, + Payment.payment_date <= end_date + ) + + if not current_user.is_admin: + query = query.join(Invoice).join(Project).join(TimeEntry).filter( + TimeEntry.user_id == current_user.id + ).distinct() + + results = query.group_by(func.date(Payment.payment_date)).all() + + # Create date range and fill missing dates with 0 + date_data = {} + current_date = start_date + while current_date <= end_date: + date_data[current_date.strftime('%Y-%m-%d')] = 0 + current_date += timedelta(days=1) + + # Fill in actual data + for date_obj, total_amount, _ in results: + if date_obj: + if isinstance(date_obj, str): + formatted_date = date_obj + else: + formatted_date = date_obj.strftime('%Y-%m-%d') + date_data[formatted_date] = float(total_amount or 0) + + return jsonify({ + 'labels': list(date_data.keys()), + 'datasets': [{ + 'label': 'Payments Received', + 'data': list(date_data.values()), + 'borderColor': '#10b981', + 'backgroundColor': 'rgba(16, 185, 129, 0.1)', + 'tension': 0.4, + 'fill': True + }] + }) + + +@analytics_bp.route('/api/analytics/payments-by-status') +@login_required +def payments_by_status(): + """Get payment breakdown by status""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + query = db.session.query( + Payment.status, + func.count(Payment.id).label('count'), + func.sum(Payment.amount).label('total_amount') + ).filter( + Payment.payment_date >= start_date, + Payment.payment_date <= end_date + ) + + if not current_user.is_admin: + query = query.join(Invoice).join(Project).join(TimeEntry).filter( + TimeEntry.user_id == current_user.id + ).distinct() + + results = query.group_by(Payment.status).all() + + labels = [] + counts = [] + amounts = [] + colors = { + 'completed': '#10b981', + 'pending': '#f59e0b', + 'failed': '#ef4444', + 'refunded': '#6b7280' + } + background_colors = [] + + for status, count, amount in results: + labels.append(status.title() if status else 'Unknown') + counts.append(count) + amounts.append(float(amount or 0)) + background_colors.append(colors.get(status, '#3b82f6')) + + return jsonify({ + 'labels': labels, + 'count_dataset': { + 'label': 'Payment Count', + 'data': counts, + 'backgroundColor': background_colors + }, + 'amount_dataset': { + 'label': 'Total Amount', + 'data': amounts, + 'backgroundColor': background_colors + } + }) + + +@analytics_bp.route('/api/analytics/payments-by-method') +@login_required +def payments_by_method(): + """Get payment breakdown by payment method""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + query = db.session.query( + Payment.method, + func.count(Payment.id).label('count'), + func.sum(Payment.amount).label('total_amount') + ).filter( + Payment.payment_date >= start_date, + Payment.payment_date <= end_date, + Payment.method.isnot(None) + ) + + if not current_user.is_admin: + query = query.join(Invoice).join(Project).join(TimeEntry).filter( + TimeEntry.user_id == current_user.id + ).distinct() + + results = query.group_by(Payment.method).order_by(func.sum(Payment.amount).desc()).all() + + labels = [] + amounts = [] + colors = [ + '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', + '#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1' + ] + + for idx, (method, _, amount) in enumerate(results): + labels.append(method.replace('_', ' ').title() if method else 'Other') + amounts.append(float(amount or 0)) + + return jsonify({ + 'labels': labels, + 'datasets': [{ + 'label': 'Amount', + 'data': amounts, + 'backgroundColor': colors[:len(labels)], + 'borderWidth': 2 + }] + }) + + +@analytics_bp.route('/api/analytics/payment-summary') +@login_required +def payment_summary(): + """Get payment summary statistics""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + # Previous period + prev_end_date = start_date - timedelta(days=1) + prev_start_date = prev_end_date - timedelta(days=days) + + # Current period query + current_query = db.session.query( + func.sum(Payment.amount).label('total_amount'), + func.count(Payment.id).label('payment_count'), + func.sum(Payment.gateway_fee).label('total_fees'), + func.sum(Payment.net_amount).label('total_net') + ).filter( + Payment.payment_date >= start_date, + Payment.payment_date <= end_date + ) + + # Previous period query + prev_query = db.session.query( + func.sum(Payment.amount).label('total_amount'), + func.count(Payment.id).label('payment_count') + ).filter( + Payment.payment_date >= prev_start_date, + Payment.payment_date <= prev_end_date + ) + + if not current_user.is_admin: + current_query = current_query.join(Invoice).join(Project).join(TimeEntry).filter( + TimeEntry.user_id == current_user.id + ) + prev_query = prev_query.join(Invoice).join(Project).join(TimeEntry).filter( + TimeEntry.user_id == current_user.id + ) + + current_result = current_query.first() + prev_result = prev_query.first() + + current_amount = float(current_result.total_amount or 0) + prev_amount = float(prev_result.total_amount or 0) + amount_change = ((current_amount - prev_amount) / prev_amount * 100) if prev_amount > 0 else 0 + + current_count = current_result.payment_count or 0 + prev_count = prev_result.payment_count or 0 + count_change = ((current_count - prev_count) / prev_count * 100) if prev_count > 0 else 0 + + total_fees = float(current_result.total_fees or 0) + total_net = float(current_result.total_net or 0) + + # Get completed vs pending + status_query = db.session.query( + Payment.status, + func.sum(Payment.amount).label('amount') + ).filter( + Payment.payment_date >= start_date, + Payment.payment_date <= end_date + ) + + if not current_user.is_admin: + status_query = status_query.join(Invoice).join(Project).join(TimeEntry).filter( + TimeEntry.user_id == current_user.id + ) + + status_results = status_query.group_by(Payment.status).all() + + completed_amount = 0 + pending_amount = 0 + + for status, amount in status_results: + if status == 'completed': + completed_amount = float(amount or 0) + elif status == 'pending': + pending_amount = float(amount or 0) + + return jsonify({ + 'total_amount': round(current_amount, 2), + 'amount_change': round(amount_change, 1), + 'payment_count': current_count, + 'count_change': round(count_change, 1), + 'total_fees': round(total_fees, 2), + 'total_net': round(total_net, 2), + 'completed_amount': round(completed_amount, 2), + 'pending_amount': round(pending_amount, 2), + 'avg_payment': round(current_amount / current_count, 2) if current_count > 0 else 0 + }) + + +@analytics_bp.route('/api/analytics/revenue-vs-payments') +@login_required +def revenue_vs_payments(): + """Compare potential revenue (from time tracking) with actual payments""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + settings = Settings.get_settings() + currency = settings.currency + + # Get billable revenue (potential) + revenue_query = db.session.query( + func.sum(TimeEntry.duration_seconds).label('total_seconds'), + Project.hourly_rate + ).join(Project).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + TimeEntry.billable == True, + Project.billable == True, + Project.hourly_rate.isnot(None) + ) + + if not current_user.is_admin: + revenue_query = revenue_query.filter(TimeEntry.user_id == current_user.id) + + revenue_results = revenue_query.group_by(Project.hourly_rate).all() + + potential_revenue = 0 + for seconds, rate in revenue_results: + if seconds and rate: + hours = seconds / 3600 + potential_revenue += hours * float(rate) + + # Get actual payments + payment_query = db.session.query( + func.sum(Payment.amount).label('total_amount') + ).filter( + Payment.payment_date >= start_date, + Payment.payment_date <= end_date, + Payment.status == 'completed' + ) + + if not current_user.is_admin: + payment_query = payment_query.join(Invoice).join(Project).join(TimeEntry).filter( + TimeEntry.user_id == current_user.id + ) + + actual_payments = payment_query.scalar() or 0 + actual_payments = float(actual_payments) + + collection_rate = (actual_payments / potential_revenue * 100) if potential_revenue > 0 else 0 + outstanding = potential_revenue - actual_payments + + return jsonify({ + 'potential_revenue': round(potential_revenue, 2), + 'actual_payments': round(actual_payments, 2), + 'outstanding': round(outstanding, 2), + 'collection_rate': round(collection_rate, 1), + 'currency': currency, + 'labels': ['Collected', 'Outstanding'], + 'data': [round(actual_payments, 2), round(outstanding, 2) if outstanding > 0 else 0] + }) diff --git a/app/routes/payments.py b/app/routes/payments.py new file mode 100644 index 0000000..3c4eda6 --- /dev/null +++ b/app/routes/payments.py @@ -0,0 +1,481 @@ +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 Payment, Invoice, User, Client +from datetime import datetime, date +from decimal import Decimal, InvalidOperation +from sqlalchemy import func, and_, or_ +from app.utils.db import safe_commit + +payments_bp = Blueprint('payments', __name__) + +@payments_bp.route('/payments') +@login_required +def list_payments(): + """List all payments""" + # Get filter parameters + status_filter = request.args.get('status', '') + method_filter = request.args.get('method', '') + date_from = request.args.get('date_from', '') + date_to = request.args.get('date_to', '') + invoice_id = request.args.get('invoice_id', type=int) + + # Base query + query = Payment.query + + # Apply filters based on user role + if not current_user.is_admin: + # Regular users can only see payments for their own invoices + query = query.join(Invoice).filter(Invoice.created_by == current_user.id) + + # Apply status filter + if status_filter: + query = query.filter(Payment.status == status_filter) + + # Apply payment method filter + if method_filter: + query = query.filter(Payment.method == method_filter) + + # Apply date range filter + if date_from: + try: + date_from_obj = datetime.strptime(date_from, '%Y-%m-%d').date() + query = query.filter(Payment.payment_date >= date_from_obj) + except ValueError: + flash('Invalid from date format', 'error') + + if date_to: + try: + date_to_obj = datetime.strptime(date_to, '%Y-%m-%d').date() + query = query.filter(Payment.payment_date <= date_to_obj) + except ValueError: + flash('Invalid to date format', 'error') + + # Apply invoice filter + if invoice_id: + query = query.filter(Payment.invoice_id == invoice_id) + + # Get payments + payments = query.order_by(Payment.payment_date.desc(), Payment.created_at.desc()).all() + + # Calculate summary statistics + total_payments = len(payments) + total_amount = sum(payment.amount for payment in payments) + total_fees = sum(payment.gateway_fee or Decimal('0') for payment in payments) + total_net = sum(payment.net_amount or payment.amount for payment in payments) + + # Status breakdown + completed_payments = [p for p in payments if p.status == 'completed'] + pending_payments = [p for p in payments if p.status == 'pending'] + failed_payments = [p for p in payments if p.status == 'failed'] + refunded_payments = [p for p in payments if p.status == 'refunded'] + + summary = { + 'total_payments': total_payments, + 'total_amount': float(total_amount), + 'total_fees': float(total_fees), + 'total_net': float(total_net), + 'completed_count': len(completed_payments), + 'completed_amount': float(sum(p.amount for p in completed_payments)), + 'pending_count': len(pending_payments), + 'pending_amount': float(sum(p.amount for p in pending_payments)), + 'failed_count': len(failed_payments), + 'refunded_count': len(refunded_payments), + 'refunded_amount': float(sum(p.amount for p in refunded_payments)) + } + + # Get unique payment methods for filter dropdown + payment_methods = db.session.query(Payment.method).distinct().filter(Payment.method.isnot(None)).all() + payment_methods = [method[0] for method in payment_methods] + + # Track event + track_event(current_user.id, 'payments_viewed', properties={ + 'total_payments': total_payments, + 'filters_applied': bool(status_filter or method_filter or date_from or date_to or invoice_id) + }) + + return render_template('payments/list.html', + payments=payments, + summary=summary, + payment_methods=payment_methods, + filters={ + 'status': status_filter, + 'method': method_filter, + 'date_from': date_from, + 'date_to': date_to, + 'invoice_id': invoice_id + }) + +@payments_bp.route('/payments/') +@login_required +def view_payment(payment_id): + """View payment details""" + payment = Payment.query.get_or_404(payment_id) + + # Check access permissions + if not current_user.is_admin and payment.invoice.created_by != current_user.id: + flash('You do not have permission to view this payment', 'error') + return redirect(url_for('payments.list_payments')) + + return render_template('payments/view.html', payment=payment) + +@payments_bp.route('/payments/create', methods=['GET', 'POST']) +@login_required +def create_payment(): + """Create a new payment""" + if request.method == 'POST': + # Get form data + invoice_id = request.form.get('invoice_id', type=int) + amount_str = request.form.get('amount', '0').strip() + currency = request.form.get('currency', '').strip() + payment_date_str = request.form.get('payment_date', '').strip() + method = request.form.get('method', '').strip() + reference = request.form.get('reference', '').strip() + notes = request.form.get('notes', '').strip() + status = request.form.get('status', 'completed').strip() + gateway_transaction_id = request.form.get('gateway_transaction_id', '').strip() + gateway_fee_str = request.form.get('gateway_fee', '0').strip() + + # Validate required fields + if not invoice_id or not amount_str or not payment_date_str: + flash('Invoice, amount, and payment date are required', 'error') + invoices = get_user_invoices() + return render_template('payments/create.html', invoices=invoices) + + # Get invoice + invoice = Invoice.query.get(invoice_id) + if not invoice: + flash('Selected invoice not found', 'error') + invoices = get_user_invoices() + return render_template('payments/create.html', invoices=invoices) + + # Check access permissions + if not current_user.is_admin and invoice.created_by != current_user.id: + flash('You do not have permission to add payments to this invoice', 'error') + return redirect(url_for('payments.list_payments')) + + # Validate and parse amount + try: + amount = Decimal(amount_str) + if amount <= 0: + flash('Payment amount must be greater than zero', 'error') + invoices = get_user_invoices() + return render_template('payments/create.html', invoices=invoices) + except (ValueError, InvalidOperation): + flash('Invalid payment amount', 'error') + invoices = get_user_invoices() + return render_template('payments/create.html', invoices=invoices) + + # Validate and parse payment date + try: + payment_date = datetime.strptime(payment_date_str, '%Y-%m-%d').date() + except ValueError: + flash('Invalid payment date format', 'error') + invoices = get_user_invoices() + return render_template('payments/create.html', invoices=invoices) + + # Parse gateway fee if provided + gateway_fee = None + if gateway_fee_str: + try: + gateway_fee = Decimal(gateway_fee_str) + if gateway_fee < 0: + flash('Gateway fee cannot be negative', 'error') + invoices = get_user_invoices() + return render_template('payments/create.html', invoices=invoices) + except (ValueError, InvalidOperation): + flash('Invalid gateway fee amount', 'error') + invoices = get_user_invoices() + return render_template('payments/create.html', invoices=invoices) + + # Create payment + payment = Payment( + invoice_id=invoice_id, + amount=amount, + currency=currency if currency else invoice.currency_code, + payment_date=payment_date, + method=method if method else None, + reference=reference if reference else None, + notes=notes if notes else None, + status=status, + received_by=current_user.id, + gateway_transaction_id=gateway_transaction_id if gateway_transaction_id else None, + gateway_fee=gateway_fee, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + # Calculate net amount + payment.calculate_net_amount() + + db.session.add(payment) + + # Update invoice payment tracking if payment is completed + if status == 'completed': + invoice.amount_paid = (invoice.amount_paid or Decimal('0')) + amount + invoice.update_payment_status() + + # Update invoice status if fully paid + if invoice.payment_status == 'fully_paid': + invoice.status = 'paid' + + if not safe_commit('create_payment', {'invoice_id': invoice_id, 'amount': float(amount)}): + flash('Could not create payment due to a database error. Please check server logs.', 'error') + invoices = get_user_invoices() + return render_template('payments/create.html', invoices=invoices) + + # Track event + track_event(current_user.id, 'payment_created', properties={ + 'payment_id': payment.id, + 'invoice_id': invoice_id, + 'amount': float(amount), + 'method': method, + 'status': status + }) + + flash(f'Payment of {amount} {currency or invoice.currency_code} recorded successfully', 'success') + return redirect(url_for('payments.view_payment', payment_id=payment.id)) + + # GET request - show form + invoices = get_user_invoices() + + # Pre-select invoice if provided in query params + selected_invoice_id = request.args.get('invoice_id', type=int) + selected_invoice = None + if selected_invoice_id: + selected_invoice = Invoice.query.get(selected_invoice_id) + if selected_invoice and (current_user.is_admin or selected_invoice.created_by == current_user.id): + pass + else: + selected_invoice = None + + today = date.today().strftime('%Y-%m-%d') + + return render_template('payments/create.html', + invoices=invoices, + selected_invoice=selected_invoice, + today=today) + +@payments_bp.route('/payments//edit', methods=['GET', 'POST']) +@login_required +def edit_payment(payment_id): + """Edit payment""" + payment = Payment.query.get_or_404(payment_id) + + # Check access permissions + if not current_user.is_admin and payment.invoice.created_by != current_user.id: + flash('You do not have permission to edit this payment', 'error') + return redirect(url_for('payments.list_payments')) + + if request.method == 'POST': + # Store old amount for invoice update + old_amount = payment.amount + old_status = payment.status + + # Get form data + amount_str = request.form.get('amount', '0').strip() + currency = request.form.get('currency', '').strip() + payment_date_str = request.form.get('payment_date', '').strip() + method = request.form.get('method', '').strip() + reference = request.form.get('reference', '').strip() + notes = request.form.get('notes', '').strip() + status = request.form.get('status', 'completed').strip() + gateway_transaction_id = request.form.get('gateway_transaction_id', '').strip() + gateway_fee_str = request.form.get('gateway_fee', '0').strip() + + # Validate and parse amount + try: + amount = Decimal(amount_str) + if amount <= 0: + flash('Payment amount must be greater than zero', 'error') + return render_template('payments/edit.html', payment=payment) + except (ValueError, InvalidOperation): + flash('Invalid payment amount', 'error') + return render_template('payments/edit.html', payment=payment) + + # Validate and parse payment date + try: + payment_date = datetime.strptime(payment_date_str, '%Y-%m-%d').date() + except ValueError: + flash('Invalid payment date format', 'error') + return render_template('payments/edit.html', payment=payment) + + # Parse gateway fee if provided + gateway_fee = None + if gateway_fee_str: + try: + gateway_fee = Decimal(gateway_fee_str) + if gateway_fee < 0: + flash('Gateway fee cannot be negative', 'error') + return render_template('payments/edit.html', payment=payment) + except (ValueError, InvalidOperation): + flash('Invalid gateway fee amount', 'error') + return render_template('payments/edit.html', payment=payment) + + # Update payment + payment.amount = amount + payment.currency = currency if currency else payment.invoice.currency_code + payment.payment_date = payment_date + payment.method = method if method else None + payment.reference = reference if reference else None + payment.notes = notes if notes else None + payment.status = status + payment.gateway_transaction_id = gateway_transaction_id if gateway_transaction_id else None + payment.gateway_fee = gateway_fee + payment.updated_at = datetime.utcnow() + + # Calculate net amount + payment.calculate_net_amount() + + # Update invoice payment tracking + invoice = payment.invoice + + # Adjust invoice amount_paid based on old and new amounts and statuses + if old_status == 'completed': + invoice.amount_paid = (invoice.amount_paid or Decimal('0')) - old_amount + + if status == 'completed': + invoice.amount_paid = (invoice.amount_paid or Decimal('0')) + amount + + invoice.update_payment_status() + + # Update invoice status + if invoice.payment_status == 'fully_paid': + invoice.status = 'paid' + elif invoice.status == 'paid' and invoice.payment_status != 'fully_paid': + invoice.status = 'sent' + + if not safe_commit('edit_payment', {'payment_id': payment_id}): + flash('Could not update payment due to a database error. Please check server logs.', 'error') + return render_template('payments/edit.html', payment=payment) + + # Track event + track_event(current_user.id, 'payment_updated', properties={ + 'payment_id': payment.id, + 'amount': float(amount), + 'status': status + }) + + flash('Payment updated successfully', 'success') + return redirect(url_for('payments.view_payment', payment_id=payment.id)) + + # GET request - show edit form + return render_template('payments/edit.html', payment=payment) + +@payments_bp.route('/payments//delete', methods=['POST']) +@login_required +def delete_payment(payment_id): + """Delete payment""" + payment = Payment.query.get_or_404(payment_id) + + # Check access permissions + if not current_user.is_admin and payment.invoice.created_by != current_user.id: + flash('You do not have permission to delete this payment', 'error') + return redirect(url_for('payments.list_payments')) + + # Store info for invoice update + invoice = payment.invoice + amount = payment.amount + status = payment.status + + # Update invoice payment tracking if payment was completed + if status == 'completed': + invoice.amount_paid = max(Decimal('0'), (invoice.amount_paid or Decimal('0')) - amount) + invoice.update_payment_status() + + # Update invoice status if no longer paid + if invoice.status == 'paid' and invoice.payment_status != 'fully_paid': + invoice.status = 'sent' + + db.session.delete(payment) + + if not safe_commit('delete_payment', {'payment_id': payment_id}): + flash('Could not delete payment due to a database error. Please check server logs.', 'error') + return redirect(url_for('payments.view_payment', payment_id=payment_id)) + + # Track event + track_event(current_user.id, 'payment_deleted', properties={ + 'payment_id': payment_id, + 'invoice_id': invoice.id + }) + + flash('Payment deleted successfully', 'success') + return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id)) + +@payments_bp.route('/api/payments/stats') +@login_required +def payment_stats(): + """Get payment statistics""" + # Base query based on user role + query = Payment.query + if not current_user.is_admin: + query = query.join(Invoice).filter(Invoice.created_by == current_user.id) + + # Get date range from request + date_from = request.args.get('date_from', '') + date_to = request.args.get('date_to', '') + + if date_from: + try: + date_from_obj = datetime.strptime(date_from, '%Y-%m-%d').date() + query = query.filter(Payment.payment_date >= date_from_obj) + except ValueError: + pass + + if date_to: + try: + date_to_obj = datetime.strptime(date_to, '%Y-%m-%d').date() + query = query.filter(Payment.payment_date <= date_to_obj) + except ValueError: + pass + + payments = query.all() + + # Calculate statistics + stats = { + 'total_payments': len(payments), + 'total_amount': float(sum(p.amount for p in payments)), + 'total_fees': float(sum(p.gateway_fee or Decimal('0') for p in payments)), + 'total_net': float(sum(p.net_amount or p.amount for p in payments)), + 'by_method': {}, + 'by_status': {}, + 'by_month': {} + } + + # Group by payment method + for payment in payments: + method = payment.method or 'Unknown' + if method not in stats['by_method']: + stats['by_method'][method] = {'count': 0, 'amount': 0} + stats['by_method'][method]['count'] += 1 + stats['by_method'][method]['amount'] += float(payment.amount) + + # Group by status + for payment in payments: + status = payment.status + if status not in stats['by_status']: + stats['by_status'][status] = {'count': 0, 'amount': 0} + stats['by_status'][status]['count'] += 1 + stats['by_status'][status]['amount'] += float(payment.amount) + + # Group by month + for payment in payments: + month_key = payment.payment_date.strftime('%Y-%m') + if month_key not in stats['by_month']: + stats['by_month'][month_key] = {'count': 0, 'amount': 0} + stats['by_month'][month_key]['count'] += 1 + stats['by_month'][month_key]['amount'] += float(payment.amount) + + return jsonify(stats) + +def get_user_invoices(): + """Get invoices accessible by current user""" + if current_user.is_admin: + return Invoice.query.filter(Invoice.status != 'cancelled').order_by(Invoice.invoice_number.desc()).all() + else: + return Invoice.query.filter( + Invoice.created_by == current_user.id, + Invoice.status != 'cancelled' + ).order_by(Invoice.invoice_number.desc()).all() + diff --git a/app/routes/reports.py b/app/routes/reports.py index 9d16197..5dd4624 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -1,9 +1,9 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file from flask_login import login_required, current_user from app import db, log_event, track_event -from app.models import User, Project, TimeEntry, Settings, Task, ProjectCost, Client +from app.models import User, Project, TimeEntry, Settings, Task, ProjectCost, Client, Payment, Invoice from datetime import datetime, timedelta -from sqlalchemy import or_ +from sqlalchemy import or_, func import csv import io import pytz @@ -40,11 +40,31 @@ def reports(): total_seconds = totals_query.scalar() or 0 billable_seconds = billable_query.scalar() or 0 + # Get payment statistics (last 30 days) + payment_query = db.session.query( + func.sum(Payment.amount).label('total_payments'), + func.count(Payment.id).label('payment_count'), + func.sum(Payment.gateway_fee).label('total_fees') + ).filter( + Payment.payment_date >= datetime.utcnow() - timedelta(days=30), + Payment.status == 'completed' + ) + + if not current_user.is_admin: + payment_query = payment_query.join(Invoice).join(Project).join(TimeEntry).filter( + TimeEntry.user_id == current_user.id + ) + + payment_result = payment_query.first() + summary = { 'total_hours': round(total_seconds / 3600, 2), 'billable_hours': round(billable_seconds / 3600, 2), 'active_projects': Project.query.filter_by(status='active').count(), 'total_users': User.query.filter_by(is_active=True).count(), + 'total_payments': float(payment_result.total_payments or 0) if payment_result else 0, + 'payment_count': payment_result.payment_count or 0 if payment_result else 0, + 'payment_fees': float(payment_result.total_fees or 0) if payment_result else 0, } recent_entries = entries_query.order_by(TimeEntry.start_time.desc()).limit(10).all() diff --git a/app/templates/analytics/dashboard_improved.html b/app/templates/analytics/dashboard_improved.html index df97c24..d217382 100644 --- a/app/templates/analytics/dashboard_improved.html +++ b/app/templates/analytics/dashboard_improved.html @@ -126,6 +126,42 @@ + +
+
+

{{ _('Payments Over Time') }}

+
+
+
+

{{ _('Payment Status') }}

+
+
+
+ + +
+
+

{{ _('Payment Methods') }}

+
+
+
+

{{ _('Revenue vs Payments') }}

+
+ +
+
+
+
{{ _('Potential Revenue') }}
+
-
+
+
+
{{ _('Collection Rate') }}
+
-
+
+
+
+
+
@@ -348,6 +384,10 @@ class EnhancedAnalyticsDashboard { this.loadBillableChart(), this.loadTaskStatusChart(), this.loadRevenueChart(), + this.loadPaymentsOverTimeChart(), + this.loadPaymentStatusChart(), + this.loadPaymentMethodChart(), + this.loadRevenueVsPaymentsChart(), this.loadProjectChart(), this.loadWeeklyTrendsChart(), this.loadHourlyChart(), @@ -455,6 +495,170 @@ class EnhancedAnalyticsDashboard { this.charts.completionRate = new Chart(ctx, { type: 'bar', data: { labels: data.project_labels, datasets: [{ label: i18n_analytics.completion_rate_label || 'Completion Rate (%)', data: data.project_completion_rates, backgroundColor: 'rgba(16, 185, 129, 0.8)', borderColor: '#10b981', borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, max: 100, title: { display: true, text: '%' } }, x: { ticks: { maxRotation: 45, minRotation: 45 } } } } }); } + async loadPaymentsOverTimeChart() { + const response = await fetch(`/api/analytics/payments-over-time?days=${this.timeRange}`); + const data = await response.json(); + const ctx = document.getElementById('paymentsOverTimeChart').getContext('2d'); + this.charts.paymentsOverTime = new Chart(ctx, { + type: 'line', + data, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: 'rgba(255,255,255,0.9)', + titleColor: '#111827', + bodyColor: '#6b7280', + borderColor: '#e5e7eb', + borderWidth: 1, + padding: 12, + callbacks: { + label: (c) => `${this.currency} ${this.formatNumber(c.parsed.y)}` + } + } + }, + scales: { + y: { + beginAtZero: true, + title: { display: true, text: `Amount (${this.currency})` }, + grid: { color: '#f3f4f6' } + }, + x: { + title: { display: true, text: 'Date' }, + grid: { display: false }, + ticks: { maxRotation: 45, minRotation: 45 } + } + } + } + }); + } + + async loadPaymentStatusChart() { + const response = await fetch(`/api/analytics/payments-by-status?days=${this.timeRange}`); + const data = await response.json(); + const ctx = document.getElementById('paymentStatusChart').getContext('2d'); + this.charts.paymentStatus = new Chart(ctx, { + type: 'doughnut', + data: { + labels: data.labels, + datasets: [{ + data: data.amount_dataset.data, + backgroundColor: data.amount_dataset.backgroundColor, + borderWidth: 2, + borderColor: '#fff' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom' }, + tooltip: { + backgroundColor: 'rgba(255,255,255,0.9)', + titleColor: '#111827', + bodyColor: '#6b7280', + borderColor: '#e5e7eb', + borderWidth: 1, + padding: 12, + callbacks: { + label: (ctx) => { + const label = ctx.label || ''; + const value = ctx.parsed || 0; + return `${label}: ${this.currency} ${this.formatNumber(value)}`; + } + } + } + } + } + }); + } + + async loadPaymentMethodChart() { + const response = await fetch(`/api/analytics/payments-by-method?days=${this.timeRange}`); + const data = await response.json(); + const ctx = document.getElementById('paymentMethodChart').getContext('2d'); + this.charts.paymentMethod = new Chart(ctx, { + type: 'bar', + data, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: 'rgba(255,255,255,0.9)', + titleColor: '#111827', + bodyColor: '#6b7280', + borderColor: '#e5e7eb', + borderWidth: 1, + padding: 12, + callbacks: { + label: (c) => `${this.currency} ${this.formatNumber(c.parsed.y)}` + } + } + }, + scales: { + y: { + beginAtZero: true, + title: { display: true, text: `Amount (${this.currency})` } + }, + x: { + ticks: { maxRotation: 45, minRotation: 45 } + } + } + } + }); + } + + async loadRevenueVsPaymentsChart() { + const response = await fetch(`/api/analytics/revenue-vs-payments?days=${this.timeRange}`); + const data = await response.json(); + + // Update summary stats + document.getElementById('potentialRevenue').textContent = `${this.currency} ${this.formatNumber(data.potential_revenue)}`; + document.getElementById('collectionRate').textContent = `${data.collection_rate}%`; + + const ctx = document.getElementById('revenueVsPaymentsChart').getContext('2d'); + this.charts.revenueVsPayments = new Chart(ctx, { + type: 'doughnut', + data: { + labels: data.labels, + datasets: [{ + data: data.data, + backgroundColor: ['#10b981', '#f59e0b'], + borderWidth: 2, + borderColor: '#fff' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom' }, + tooltip: { + backgroundColor: 'rgba(255,255,255,0.9)', + titleColor: '#111827', + bodyColor: '#6b7280', + borderColor: '#e5e7eb', + borderWidth: 1, + padding: 12, + callbacks: { + label: (ctx) => { + const label = ctx.label || ''; + const value = ctx.parsed || 0; + const total = ctx.dataset.data.reduce((a, b) => a + b, 0); + const pct = total > 0 ? ((value / total) * 100).toFixed(1) : 0; + return `${label}: ${this.currency} ${this.formatNumber(value)} (${pct}%)`; + } + } + } + } + } + }); + } + {% if current_user.is_admin %} async loadUserChart() { const response = await fetch(`/api/analytics/hours-by-user?days=${this.timeRange}`); diff --git a/app/templates/base.html b/app/templates/base.html index 2126640..44f1755 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -100,7 +100,7 @@
+ + + {% if invoice.payments.count() > 0 %} +
+
+

Payment History

+ + Add Payment + +
+ + + + + + + + + + + + + {% for payment in invoice.payments.order_by('payment_date desc, created_at desc') %} + + + + + + + + + {% endfor %} + +
DateAmountMethodReferenceStatusActions
{{ payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else 'N/A' }} + {{ "%.2f"|format(payment.amount) }} {{ payment.currency or invoice.currency_code }} + {% if payment.gateway_fee %} + (Fee: {{ "%.2f"|format(payment.gateway_fee) }}) + {% endif %} + {{ payment.method or 'N/A' }}{{ payment.reference or '-' }} + {% if payment.status == 'completed' %} + + Completed + + {% elif payment.status == 'pending' %} + + Pending + + {% elif payment.status == 'failed' %} + + Failed + + {% elif payment.status == 'refunded' %} + + Refunded + + {% endif %} + + + View + +
+
+ {% else %} +
+
+

Payment History

+ + Record First Payment + +
+

No payments recorded yet.

+
+ {% endif %}
{% endblock %} diff --git a/app/templates/payments/create.html b/app/templates/payments/create.html new file mode 100644 index 0000000..392c2fd --- /dev/null +++ b/app/templates/payments/create.html @@ -0,0 +1,202 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+

Record New Payment

+ + Back to Payments + +
+ + +
+
+ + +
+ +
+ + +

Select the invoice for which you're recording this payment

+
+ + + + + +
+ + +

Payment amount

+
+ + +
+ + +

3-letter currency code (e.g., EUR, USD)

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

Payment status

+
+ + +
+ + +

Check number, transaction ID, etc.

+
+ + +
+ + +

Payment gateway transaction ID

+
+ + +
+ + +

Transaction or processing fee

+
+ + +
+ + +

Additional payment notes

+
+
+ + +
+ + Cancel + + +
+
+
+
+ + +{% endblock %} + diff --git a/app/templates/payments/edit.html b/app/templates/payments/edit.html new file mode 100644 index 0000000..b87bee7 --- /dev/null +++ b/app/templates/payments/edit.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+

Edit Payment #{{ payment.id }}

+ +
+ + +
+
+ + + + + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + Cancel + + +
+
+
+
+{% endblock %} + diff --git a/app/templates/payments/list.html b/app/templates/payments/list.html new file mode 100644 index 0000000..3cda4a3 --- /dev/null +++ b/app/templates/payments/list.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+

Payments

+ + Record Payment + +
+ + +
+
+

Total Payments

+

{{ summary.total_payments }}

+
+
+

Total Amount

+

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

+
+
+

Completed

+

{{ summary.completed_count }}

+

€{{ "%.2f"|format(summary.completed_amount) }}

+
+
+

Gateway Fees

+

€{{ "%.2f"|format(summary.total_fees) }}

+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+ + +
+ {% if payments %} +
+ + + + + + + + + + + + + + {% for payment in payments %} + + + + + + + + + + {% endfor %} + +
IDInvoiceAmountDateMethodStatusActions
#{{ payment.id }} + + {{ payment.invoice.invoice_number }} + + + + {{ payment.amount }} {{ payment.currency or 'EUR' }} + + {% if payment.gateway_fee %} + + Fee: {{ payment.gateway_fee }} {{ payment.currency or 'EUR' }} + + {% endif %} + + {{ payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else 'N/A' }} + + + {{ payment.method or 'N/A' }} + + + {% if payment.status == 'completed' %} + + Completed + + {% elif payment.status == 'pending' %} + + Pending + + {% elif payment.status == 'failed' %} + + Failed + + {% elif payment.status == 'refunded' %} + + Refunded + + {% endif %} + + View + Edit +
+
+ {% else %} +
+

No payments found.

+ Record your first payment +
+ {% endif %} +
+
+{% endblock %} + diff --git a/app/templates/payments/view.html b/app/templates/payments/view.html new file mode 100644 index 0000000..b803172 --- /dev/null +++ b/app/templates/payments/view.html @@ -0,0 +1,261 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+

Payment #{{ payment.id }}

+ +
+ +
+ +
+

Payment Details

+ +
+ +
+ +

+ {{ payment.amount }} {{ payment.currency or 'EUR' }} +

+
+ + +
+ +
+ {% if payment.status == 'completed' %} + + Completed + + {% elif payment.status == 'pending' %} + + Pending + + {% elif payment.status == 'failed' %} + + Failed + + {% elif payment.status == 'refunded' %} + + Refunded + + {% endif %} +
+
+ + +
+ +

+ {{ payment.payment_date.strftime('%B %d, %Y') if payment.payment_date else 'N/A' }} +

+
+ + +
+ +

+ {% if payment.method %} + {{ payment.method }} + {% else %} + N/A + {% endif %} +

+
+ + {% if payment.reference %} + +
+ +

{{ payment.reference }}

+
+ {% endif %} + + {% if payment.gateway_transaction_id %} + +
+ +

{{ payment.gateway_transaction_id }}

+
+ {% endif %} + + {% if payment.gateway_fee %} + +
+ +

+ {{ payment.gateway_fee }} {{ payment.currency or 'EUR' }} +

+
+ + +
+ +

+ {{ payment.net_amount or payment.amount }} {{ payment.currency or 'EUR' }} +

+
+ {% endif %} + + {% if payment.received_by %} + +
+ +

+ {{ payment.receiver.username if payment.receiver else 'Unknown' }} +

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

+ {{ payment.created_at.strftime('%B %d, %Y at %I:%M %p') if payment.created_at else 'N/A' }} +

+
+ + +
+ +

+ {{ payment.updated_at.strftime('%B %d, %Y at %I:%M %p') if payment.updated_at else 'N/A' }} +

+
+
+ + {% if payment.notes %} + +
+ +
+

{{ payment.notes }}

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

Related Invoice

+ +
+ + +
+ +

{{ payment.invoice.client_name }}

+
+ +
+ +

+ {{ payment.invoice.total_amount }} {{ payment.invoice.currency_code }} +

+
+ +
+ +

+ {{ payment.invoice.amount_paid or 0 }} {{ payment.invoice.currency_code }} +

+
+ +
+ +

+ {{ payment.invoice.outstanding_amount }} {{ payment.invoice.currency_code }} +

+
+ +
+ + {% if payment.invoice.payment_status == 'fully_paid' %} + + Fully Paid + + {% elif payment.invoice.payment_status == 'partially_paid' %} + + Partially Paid + + {% else %} + + Unpaid + + {% endif %} +
+ + +
+
+ + +
+

Actions

+ +
+ + Edit Payment + + +
+ + +
+
+
+
+
+
+{% endblock %} + +{% block scripts_extra %} + +{% endblock %} + diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html index f6da103..a94414a 100644 --- a/app/templates/reports/index.html +++ b/app/templates/reports/index.html @@ -13,6 +13,41 @@ {{ info_card("Active Users", summary.total_users, "Currently active") }} +
+
+
+ +
+
€{{ "%.2f"|format(summary.total_payments) }}
+
Total Payments
+
Last 30 days
+
+
+
+ +
+
{{ summary.payment_count }}
+
Payments Received
+
Last 30 days
+
+
+
+ +
+
€{{ "%.2f"|format(summary.payment_fees) }}
+
Gateway Fees
+
Last 30 days
+
+
+
+ +
+
€{{ "%.2f"|format(summary.total_payments - summary.payment_fees) }}
+
Net Received
+
After fees
+
+
+

Report Types

diff --git a/docs/PAYMENT_TRACKING.md b/docs/PAYMENT_TRACKING.md new file mode 100644 index 0000000..e69503f --- /dev/null +++ b/docs/PAYMENT_TRACKING.md @@ -0,0 +1,407 @@ +# Payment Tracking Feature + +## Overview + +The Payment Tracking feature provides comprehensive payment management capabilities for invoices in the TimeTracker application. It allows users to record, track, and manage payments received against invoices, including support for partial payments, multiple payment methods, payment gateways, and detailed payment history. + +## Features + +### Core Functionality + +- **Payment Recording**: Record payments against invoices with detailed information +- **Multiple Payment Methods**: Support for various payment methods (bank transfer, cash, check, credit card, PayPal, Stripe, etc.) +- **Payment Status Tracking**: Track payment status (completed, pending, failed, refunded) +- **Partial Payments**: Support for multiple partial payments against a single invoice +- **Payment Gateway Integration**: Track gateway transaction IDs and processing fees +- **Payment History**: View complete payment history for each invoice +- **Filtering and Search**: Filter payments by status, method, date range, and invoice +- **Payment Statistics**: View payment statistics and analytics + +### Payment Model Fields + +The Payment model includes the following fields: + +| Field | Type | Description | +|-------|------|-------------| +| id | Integer | Primary key | +| invoice_id | Integer | Foreign key to invoice | +| amount | Decimal(10,2) | Payment amount | +| currency | String(3) | Currency code (e.g., EUR, USD) | +| payment_date | Date | Date payment was received | +| method | String(50) | Payment method | +| reference | String(100) | Transaction reference or check number | +| notes | Text | Additional payment notes | +| status | String(20) | Payment status (completed, pending, failed, refunded) | +| received_by | Integer | User who recorded the payment | +| gateway_transaction_id | String(255) | Payment gateway transaction ID | +| gateway_fee | Decimal(10,2) | Gateway processing fee | +| net_amount | Decimal(10,2) | Net amount after fees | +| created_at | DateTime | Payment record creation timestamp | +| updated_at | DateTime | Last update timestamp | + +## Usage + +### Recording a Payment + +1. Navigate to **Payments** → **Record Payment** or click **Record Payment** on an invoice +2. Select the invoice (if not pre-selected) +3. Enter payment details: + - **Amount**: Payment amount received + - **Currency**: Currency code (defaults to invoice currency) + - **Payment Date**: Date payment was received + - **Payment Method**: Select from available methods + - **Status**: Payment status (default: completed) + - **Reference**: Transaction ID, check number, etc. + - **Gateway Transaction ID**: For payment gateway transactions + - **Gateway Fee**: Processing fee charged by gateway + - **Notes**: Additional information +4. Click **Record Payment** + +### Viewing Payments + +#### Payment List View + +Navigate to **Payments** to see all payments. The list view includes: + +- Summary cards showing: + - Total number of payments + - Total payment amount + - Completed payments count and amount + - Total gateway fees +- Filterable table with: + - Payment ID + - Invoice number (clickable) + - Amount and currency + - Payment date + - Payment method + - Status badge + - Actions (View, Edit) + +#### Individual Payment View + +Click on a payment to view detailed information including: + +- Payment amount and status +- Payment date and method +- Reference and transaction IDs +- Gateway fee and net amount +- Received by information +- Related invoice details +- Creation and update timestamps +- Notes + +### Editing a Payment + +1. Navigate to the payment detail view +2. Click **Edit Payment** +3. Update the desired fields +4. Click **Update Payment** + +**Note**: Editing a payment will automatically update the invoice's payment status and outstanding amount. + +### Deleting a Payment + +1. Navigate to the payment detail view +2. Click **Delete Payment** +3. Confirm the deletion + +**Note**: Deleting a payment will automatically adjust the invoice's payment status and outstanding amount. + +### Filtering Payments + +Use the filters on the payment list page to narrow down results: + +- **Status**: Filter by payment status +- **Payment Method**: Filter by payment method +- **Date Range**: Filter by payment date range (from/to) +- **Invoice**: View payments for a specific invoice + +### Invoice Integration + +#### Payment History on Invoice + +Each invoice view now includes a Payment History section showing: + +- List of all payments made against the invoice +- Payment date, amount, method, reference, and status +- Total amount paid +- Outstanding amount +- Quick link to add new payment + +#### Payment Status on Invoice + +Invoices display: + +- **Total Amount**: Invoice total +- **Amount Paid**: Sum of completed payments +- **Outstanding Amount**: Remaining balance +- **Payment Status**: Badge showing payment status (unpaid, partially paid, fully paid) + +## Payment Methods + +Supported payment methods include: + +- Bank Transfer +- Cash +- Check +- Credit Card +- Debit Card +- PayPal +- Stripe +- Wire Transfer +- Other + +## Payment Statuses + +### Completed +Payment has been successfully received and processed. + +### Pending +Payment is awaiting confirmation or processing. + +### Failed +Payment attempt failed or was declined. + +### Refunded +Payment was refunded to the customer. + +## API Endpoints + +### List Payments +``` +GET /payments +``` +Query parameters: +- `status`: Filter by status +- `method`: Filter by payment method +- `date_from`: Filter by start date +- `date_to`: Filter by end date +- `invoice_id`: Filter by invoice + +### View Payment +``` +GET /payments/ +``` + +### Create Payment +``` +GET /payments/create +POST /payments/create +``` + +Form data: +- `invoice_id` (required) +- `amount` (required) +- `currency` +- `payment_date` (required) +- `method` +- `reference` +- `status` +- `gateway_transaction_id` +- `gateway_fee` +- `notes` + +### Edit Payment +``` +GET /payments//edit +POST /payments//edit +``` + +### Delete Payment +``` +POST /payments//delete +``` + +### Payment Statistics +``` +GET /api/payments/stats +``` +Query parameters: +- `date_from`: Start date for statistics +- `date_to`: End date for statistics + +Returns JSON with: +- Total payments count and amount +- Total fees and net amount +- Breakdown by payment method +- Breakdown by status +- Monthly statistics + +## Database Schema + +### Payments Table + +```sql +CREATE TABLE payments ( + id INTEGER PRIMARY KEY, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + amount NUMERIC(10, 2) NOT NULL, + currency VARCHAR(3), + payment_date DATE NOT NULL, + method VARCHAR(50), + reference VARCHAR(100), + notes TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'completed', + received_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + gateway_transaction_id VARCHAR(255), + gateway_fee NUMERIC(10, 2), + net_amount NUMERIC(10, 2), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE INDEX ix_payments_invoice_id ON payments(invoice_id); +CREATE INDEX ix_payments_payment_date ON payments(payment_date); +CREATE INDEX ix_payments_status ON payments(status); +CREATE INDEX ix_payments_received_by ON payments(received_by); +``` + +## Migration + +The payment tracking feature includes an Alembic migration (`035_enhance_payments_table.py`) that: + +1. Creates the payments table if it doesn't exist +2. Adds enhanced tracking fields (status, received_by, gateway fields) +3. Creates necessary indexes for performance +4. Sets up foreign key relationships + +To apply the migration: + +```bash +# Using Alembic +alembic upgrade head + +# Or using Flask-Migrate +flask db upgrade +``` + +## Best Practices + +### Recording Payments + +1. **Record payments promptly**: Keep payment records up-to-date +2. **Use reference numbers**: Always include transaction IDs or check numbers +3. **Document gateway fees**: Record processing fees for accurate accounting +4. **Add notes**: Include any relevant context or special circumstances +5. **Verify amounts**: Double-check payment amounts match actual receipts + +### Payment Status Management + +1. **Pending payments**: Use for payments awaiting clearance +2. **Failed payments**: Record failed attempts for tracking +3. **Refunds**: Use refunded status and create negative payments if needed +4. **Partial payments**: Record each payment separately for clear audit trail + +### Security and Permissions + +1. Regular users can only manage payments for their own invoices +2. Admins can manage all payments +3. Payment deletion adjusts invoice status automatically +4. All payment actions are logged with user information + +## Troubleshooting + +### Payment Not Updating Invoice Status + +- Ensure payment status is set to "completed" +- Verify invoice ID is correct +- Check that payment amount is valid +- Refresh the invoice page to see updates + +### Gateway Fee Not Calculating + +- Ensure gateway fee field is populated +- Payment model automatically calculates net amount +- Call `calculate_net_amount()` method if needed + +### Missing Payment Methods + +- Payment methods can be customized in the route handler +- Add new methods to the dropdown in create/edit templates +- Methods are stored as strings in the database + +## Testing + +The payment tracking feature includes comprehensive tests: + +### Unit Tests (`tests/test_payment_model.py`) +- Payment model creation and validation +- Net amount calculation +- Payment-invoice relationships +- Payment-user relationships +- Multiple payments per invoice +- Status handling + +### Route Tests (`tests/test_payment_routes.py`) +- All CRUD operations +- Access control and permissions +- Filtering and searching +- Invalid input handling +- Payment statistics API + +### Smoke Tests (`tests/test_payment_smoke.py`) +- Basic functionality verification +- Template existence +- Database schema +- End-to-end workflow +- Integration with invoices + +Run tests with: + +```bash +# All payment tests +pytest tests/test_payment*.py + +# Specific test file +pytest tests/test_payment_model.py -v + +# Smoke tests only +pytest tests/test_payment_smoke.py -v +``` + +## Future Enhancements + +Potential improvements for future versions: + +1. **Payment Reminders**: Automated reminders for overdue invoices +2. **Payment Plans**: Support for installment payment schedules +3. **Recurring Payments**: Automatic payment processing for recurring invoices +4. **Payment Export**: Export payment history to CSV/Excel +5. **Payment Reconciliation**: Bank statement matching and reconciliation +6. **Multi-Currency**: Enhanced multi-currency support with exchange rates +7. **Payment Gateway Integration**: Direct integration with payment processors +8. **Payment Notifications**: Email notifications for payment receipt +9. **Payment Reports**: Advanced reporting and analytics +10. **Bulk Payment Import**: Import payments from CSV/Excel + +## Related Features + +- **Invoices**: Core invoicing functionality +- **Clients**: Client management and billing +- **Reports**: Financial reporting including payment analytics +- **Analytics**: Payment trends and statistics + +## Support + +For issues or questions about payment tracking: + +1. Check this documentation +2. Review the test files for usage examples +3. Check the application logs for error messages +4. Consult the TimeTracker documentation + +## Changelog + +### Version 1.0 (2025-10-27) + +Initial release of Payment Tracking feature: + +- Complete payment CRUD operations +- Multiple payment methods support +- Payment status tracking +- Gateway integration support +- Payment filtering and search +- Invoice integration +- Comprehensive test coverage +- Full documentation + diff --git a/migrations/versions/035_enhance_payments_table.py b/migrations/versions/035_enhance_payments_table.py new file mode 100644 index 0000000..45d5815 --- /dev/null +++ b/migrations/versions/035_enhance_payments_table.py @@ -0,0 +1,120 @@ +"""enhance payments table with tracking features + +Revision ID: 035_enhance_payments +Revises: 034_add_calendar_events +Create Date: 2025-10-27 00:00:00 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '035_enhance_payments' +down_revision = '034_add_calendar_events' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + # Create payments table if it doesn't exist + if 'payments' not in inspector.get_table_names(): + op.create_table( + 'payments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('invoice_id', sa.Integer(), nullable=False), + sa.Column('amount', sa.Numeric(10, 2), nullable=False), + sa.Column('currency', sa.String(3), nullable=True), + sa.Column('payment_date', sa.Date(), nullable=False), + sa.Column('method', sa.String(50), nullable=True), + sa.Column('reference', sa.String(100), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('status', sa.String(20), nullable=False, server_default='completed'), + sa.Column('received_by', sa.Integer(), nullable=True), + sa.Column('gateway_transaction_id', sa.String(255), nullable=True), + sa.Column('gateway_fee', sa.Numeric(10, 2), nullable=True), + sa.Column('net_amount', sa.Numeric(10, 2), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['received_by'], ['users.id'], ondelete='SET NULL') + ) + + # Create indexes + op.create_index('ix_payments_invoice_id', 'payments', ['invoice_id']) + op.create_index('ix_payments_payment_date', 'payments', ['payment_date']) + op.create_index('ix_payments_status', 'payments', ['status']) + op.create_index('ix_payments_received_by', 'payments', ['received_by']) + else: + # Table exists, add new columns if they don't exist + existing_columns = [col['name'] for col in inspector.get_columns('payments')] + + if 'status' not in existing_columns: + op.add_column('payments', sa.Column('status', sa.String(20), nullable=False, server_default='completed')) + + if 'received_by' not in existing_columns: + op.add_column('payments', sa.Column('received_by', sa.Integer(), nullable=True)) + try: + op.create_foreign_key('fk_payments_received_by', 'payments', 'users', ['received_by'], ['id'], ondelete='SET NULL') + except: + pass + + if 'gateway_transaction_id' not in existing_columns: + op.add_column('payments', sa.Column('gateway_transaction_id', sa.String(255), nullable=True)) + + if 'gateway_fee' not in existing_columns: + op.add_column('payments', sa.Column('gateway_fee', sa.Numeric(10, 2), nullable=True)) + + if 'net_amount' not in existing_columns: + op.add_column('payments', sa.Column('net_amount', sa.Numeric(10, 2), nullable=True)) + + # Create indexes if they don't exist + try: + op.create_index('ix_payments_status', 'payments', ['status']) + except: + pass + + try: + op.create_index('ix_payments_received_by', 'payments', ['received_by']) + except: + pass + + try: + op.create_index('ix_payments_payment_date', 'payments', ['payment_date']) + except: + pass + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if 'payments' in inspector.get_table_names(): + existing_columns = [col['name'] for col in inspector.get_columns('payments')] + + # Drop indexes + try: + op.drop_index('ix_payments_received_by', table_name='payments') + except: + pass + + try: + op.drop_index('ix_payments_status', table_name='payments') + except: + pass + + # Drop new columns if they exist + columns_to_drop = ['net_amount', 'gateway_fee', 'gateway_transaction_id', 'received_by', 'status'] + + for column in columns_to_drop: + if column in existing_columns: + try: + op.drop_column('payments', column) + except Exception as e: + print(f"Warning: Could not drop column {column}: {e}") + pass + diff --git a/tests/test_payment_model.py b/tests/test_payment_model.py new file mode 100644 index 0000000..58be704 --- /dev/null +++ b/tests/test_payment_model.py @@ -0,0 +1,369 @@ +"""Tests for Payment model""" + +import pytest +from datetime import datetime, date, timedelta +from decimal import Decimal +from app import db +from app.models import Payment, Invoice, User, Project, Client + + +@pytest.fixture +def test_user(app): + """Create a test user""" + with app.app_context(): + user = User(username='testuser', email='test@example.com') + user.role = 'user' + db.session.add(user) + db.session.commit() + yield user + # Cleanup + db.session.delete(user) + db.session.commit() + + +@pytest.fixture +def test_client(app): + """Create a test client""" + with app.app_context(): + client = Client(name='Test Client', email='client@example.com') + db.session.add(client) + db.session.commit() + yield client + # Cleanup + db.session.delete(client) + db.session.commit() + + +@pytest.fixture +def test_project(app, test_client, test_user): + """Create a test project""" + with app.app_context(): + project = Project( + name='Test Project', + client_id=test_client.id, + created_by=test_user.id, + billable=True, + hourly_rate=Decimal('100.00') + ) + db.session.add(project) + db.session.commit() + yield project + # Cleanup + db.session.delete(project) + db.session.commit() + + +@pytest.fixture +def test_invoice(app, test_project, test_user, test_client): + """Create a test invoice""" + with app.app_context(): + invoice = Invoice( + invoice_number='INV-TEST-001', + project_id=test_project.id, + client_name='Test Client', + client_id=test_client.id, + due_date=date.today() + timedelta(days=30), + created_by=test_user.id + ) + invoice.subtotal = Decimal('1000.00') + invoice.tax_rate = Decimal('21.00') + invoice.tax_amount = Decimal('210.00') + invoice.total_amount = Decimal('1210.00') + db.session.add(invoice) + db.session.commit() + yield invoice + # Cleanup + db.session.delete(invoice) + db.session.commit() + + +class TestPaymentModel: + """Test Payment model functionality""" + + def test_create_payment(self, app, test_invoice, test_user): + """Test creating a payment""" + with app.app_context(): + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + method='bank_transfer', + reference='REF-12345', + notes='Test payment', + status='completed', + received_by=test_user.id + ) + + db.session.add(payment) + db.session.commit() + + # Verify payment was created + assert payment.id is not None + assert payment.amount == Decimal('500.00') + assert payment.currency == 'EUR' + assert payment.method == 'bank_transfer' + assert payment.status == 'completed' + + # Cleanup + db.session.delete(payment) + db.session.commit() + + def test_payment_calculate_net_amount_without_fee(self, app, test_invoice): + """Test calculating net amount without gateway fee""" + with app.app_context(): + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + status='completed' + ) + + payment.calculate_net_amount() + + assert payment.net_amount == Decimal('500.00') + + # Cleanup (not in DB yet, so no cleanup needed) + + def test_payment_calculate_net_amount_with_fee(self, app, test_invoice): + """Test calculating net amount with gateway fee""" + with app.app_context(): + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + gateway_fee=Decimal('15.00'), + status='completed' + ) + + payment.calculate_net_amount() + + assert payment.net_amount == Decimal('485.00') + + def test_payment_to_dict(self, app, test_invoice, test_user): + """Test converting payment to dictionary""" + with app.app_context(): + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + method='bank_transfer', + reference='REF-12345', + notes='Test payment', + status='completed', + received_by=test_user.id, + gateway_fee=Decimal('15.00'), + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + payment.calculate_net_amount() + + db.session.add(payment) + db.session.commit() + + payment_dict = payment.to_dict() + + assert payment_dict['invoice_id'] == test_invoice.id + assert payment_dict['amount'] == 500.0 + assert payment_dict['currency'] == 'EUR' + assert payment_dict['method'] == 'bank_transfer' + assert payment_dict['reference'] == 'REF-12345' + assert payment_dict['status'] == 'completed' + assert payment_dict['gateway_fee'] == 15.0 + assert payment_dict['net_amount'] == 485.0 + + # Cleanup + db.session.delete(payment) + db.session.commit() + + def test_payment_relationship_with_invoice(self, app, test_invoice): + """Test payment relationship with invoice""" + with app.app_context(): + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + status='completed' + ) + + db.session.add(payment) + db.session.commit() + + # Refresh invoice to get updated relationships + db.session.refresh(test_invoice) + + # Verify relationship + assert payment.invoice == test_invoice + assert payment in test_invoice.payments + + # Cleanup + db.session.delete(payment) + db.session.commit() + + def test_payment_relationship_with_user(self, app, test_invoice, test_user): + """Test payment relationship with user (receiver)""" + with app.app_context(): + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + status='completed', + received_by=test_user.id + ) + + db.session.add(payment) + db.session.commit() + + # Verify relationship + assert payment.receiver == test_user + assert payment in test_user.received_payments + + # Cleanup + db.session.delete(payment) + db.session.commit() + + def test_payment_repr(self, app, test_invoice): + """Test payment string representation""" + with app.app_context(): + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + status='completed' + ) + + repr_str = repr(payment) + assert 'Payment' in repr_str + assert '500.00' in repr_str + assert 'EUR' in repr_str + + def test_multiple_payments_for_invoice(self, app, test_invoice): + """Test multiple payments for a single invoice""" + with app.app_context(): + payment1 = Payment( + invoice_id=test_invoice.id, + amount=Decimal('300.00'), + currency='EUR', + payment_date=date.today(), + status='completed' + ) + + payment2 = Payment( + invoice_id=test_invoice.id, + amount=Decimal('200.00'), + currency='EUR', + payment_date=date.today() + timedelta(days=1), + status='completed' + ) + + db.session.add_all([payment1, payment2]) + db.session.commit() + + # Refresh invoice to get updated relationships + db.session.refresh(test_invoice) + + # Verify both payments are associated with invoice + assert test_invoice.payments.count() == 2 + + # Cleanup + db.session.delete(payment1) + db.session.delete(payment2) + db.session.commit() + + def test_payment_status_values(self, app, test_invoice): + """Test different payment status values""" + with app.app_context(): + statuses = ['completed', 'pending', 'failed', 'refunded'] + + for status in statuses: + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('100.00'), + currency='EUR', + payment_date=date.today(), + status=status + ) + + db.session.add(payment) + db.session.commit() + + assert payment.status == status + + # Cleanup + db.session.delete(payment) + db.session.commit() + + +class TestPaymentIntegration: + """Test Payment model integration with Invoice""" + + def test_invoice_updates_with_payment(self, app, test_invoice): + """Test that invoice updates correctly when payment is added""" + with app.app_context(): + # Initial state + assert test_invoice.amount_paid == Decimal('0') + assert test_invoice.payment_status == 'unpaid' + + # Add payment + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('605.00'), # Half of total + currency='EUR', + payment_date=date.today(), + status='completed' + ) + + db.session.add(payment) + + # Update invoice manually (this would be done by route logic) + test_invoice.amount_paid = (test_invoice.amount_paid or Decimal('0')) + payment.amount + test_invoice.update_payment_status() + + db.session.commit() + + # Verify invoice was updated + assert test_invoice.amount_paid == Decimal('605.00') + assert test_invoice.payment_status == 'partially_paid' + + # Cleanup + db.session.delete(payment) + test_invoice.amount_paid = Decimal('0') + test_invoice.update_payment_status() + db.session.commit() + + def test_invoice_fully_paid_with_payments(self, app, test_invoice): + """Test that invoice becomes fully paid when total payments equal total amount""" + with app.app_context(): + # Add payments that equal total amount + payment = Payment( + invoice_id=test_invoice.id, + amount=test_invoice.total_amount, + currency='EUR', + payment_date=date.today(), + status='completed' + ) + + db.session.add(payment) + + # Update invoice manually (this would be done by route logic) + test_invoice.amount_paid = payment.amount + test_invoice.update_payment_status() + + db.session.commit() + + # Verify invoice is fully paid + assert test_invoice.payment_status == 'fully_paid' + assert test_invoice.is_paid is True + + # Cleanup + db.session.delete(payment) + test_invoice.amount_paid = Decimal('0') + test_invoice.update_payment_status() + db.session.commit() + diff --git a/tests/test_payment_routes.py b/tests/test_payment_routes.py new file mode 100644 index 0000000..e63a0e1 --- /dev/null +++ b/tests/test_payment_routes.py @@ -0,0 +1,423 @@ +"""Tests for Payment routes""" + +import pytest +from datetime import datetime, date, timedelta +from decimal import Decimal +from flask import url_for +from app import db +from app.models import Payment, Invoice, User, Project, Client + + +@pytest.fixture +def test_user(app): + """Create a test user""" + with app.app_context(): + user = User(username='testuser', email='test@example.com') + user.role = 'user' + db.session.add(user) + db.session.commit() + yield user + # Cleanup + db.session.delete(user) + db.session.commit() + + +@pytest.fixture +def test_admin(app): + """Create a test admin user""" + with app.app_context(): + admin = User(username='testadmin', email='admin@example.com') + admin.role = 'admin' + db.session.add(admin) + db.session.commit() + yield admin + # Cleanup + db.session.delete(admin) + db.session.commit() + + +@pytest.fixture +def test_client(app): + """Create a test client""" + with app.app_context(): + client = Client(name='Test Client', email='client@example.com') + db.session.add(client) + db.session.commit() + yield client + # Cleanup + db.session.delete(client) + db.session.commit() + + +@pytest.fixture +def test_project(app, test_client, test_user): + """Create a test project""" + with app.app_context(): + project = Project( + name='Test Project', + client_id=test_client.id, + created_by=test_user.id, + billable=True, + hourly_rate=Decimal('100.00') + ) + db.session.add(project) + db.session.commit() + yield project + # Cleanup + db.session.delete(project) + db.session.commit() + + +@pytest.fixture +def test_invoice(app, test_project, test_user, test_client): + """Create a test invoice""" + with app.app_context(): + invoice = Invoice( + invoice_number='INV-TEST-001', + project_id=test_project.id, + client_name='Test Client', + client_id=test_client.id, + due_date=date.today() + timedelta(days=30), + created_by=test_user.id + ) + invoice.subtotal = Decimal('1000.00') + invoice.tax_rate = Decimal('21.00') + invoice.tax_amount = Decimal('210.00') + invoice.total_amount = Decimal('1210.00') + db.session.add(invoice) + db.session.commit() + yield invoice + # Cleanup + db.session.delete(invoice) + db.session.commit() + + +@pytest.fixture +def test_payment(app, test_invoice, test_user): + """Create a test payment""" + with app.app_context(): + payment = Payment( + invoice_id=test_invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + method='bank_transfer', + reference='REF-12345', + status='completed', + received_by=test_user.id + ) + db.session.add(payment) + db.session.commit() + yield payment + # Cleanup + db.session.delete(payment) + db.session.commit() + + +class TestPaymentRoutes: + """Test payment routes""" + + def test_list_payments_requires_login(self, client): + """Test that listing payments requires login""" + response = client.get('/payments') + assert response.status_code == 302 # Redirect to login + + def test_list_payments_as_user(self, client, test_user, test_payment): + """Test listing payments as a regular user""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # List payments + response = client.get('/payments') + assert response.status_code == 200 + + def test_list_payments_as_admin(self, client, test_admin, test_payment): + """Test listing payments as admin""" + with client: + # Login + client.post('/login', data={ + 'username': 'testadmin' + }, follow_redirects=True) + + # List payments + response = client.get('/payments') + assert response.status_code == 200 + + def test_view_payment_requires_login(self, client, test_payment): + """Test that viewing a payment requires login""" + response = client.get(f'/payments/{test_payment.id}') + assert response.status_code == 302 # Redirect to login + + def test_view_payment(self, client, test_user, test_payment): + """Test viewing a payment""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # View payment + response = client.get(f'/payments/{test_payment.id}') + assert response.status_code == 200 + + def test_create_payment_get_requires_login(self, client): + """Test that creating payment GET requires login""" + response = client.get('/payments/create') + assert response.status_code == 302 # Redirect to login + + def test_create_payment_get(self, client, test_user): + """Test creating payment GET request""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Get create form + response = client.get('/payments/create') + assert response.status_code == 200 + + def test_create_payment_post(self, client, test_user, test_invoice, app): + """Test creating a payment via POST""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Get CSRF token + response = client.get('/payments/create') + + # Create payment + payment_data = { + 'invoice_id': test_invoice.id, + 'amount': '500.00', + 'currency': 'EUR', + 'payment_date': date.today().strftime('%Y-%m-%d'), + 'method': 'bank_transfer', + 'reference': 'TEST-REF-001', + 'status': 'completed', + 'notes': 'Test payment' + } + + response = client.post('/payments/create', data=payment_data, follow_redirects=True) + assert response.status_code == 200 + + # Verify payment was created + with app.app_context(): + payment = Payment.query.filter_by(reference='TEST-REF-001').first() + assert payment is not None + assert payment.amount == Decimal('500.00') + + # Cleanup + db.session.delete(payment) + db.session.commit() + + def test_create_payment_with_gateway_fee(self, client, test_user, test_invoice, app): + """Test creating a payment with gateway fee""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Create payment with gateway fee + payment_data = { + 'invoice_id': test_invoice.id, + 'amount': '500.00', + 'currency': 'EUR', + 'payment_date': date.today().strftime('%Y-%m-%d'), + 'method': 'stripe', + 'gateway_fee': '15.00', + 'status': 'completed' + } + + response = client.post('/payments/create', data=payment_data, follow_redirects=True) + assert response.status_code == 200 + + # Verify payment was created with fee + with app.app_context(): + payment = Payment.query.filter_by(invoice_id=test_invoice.id, method='stripe').first() + if payment: + assert payment.gateway_fee == Decimal('15.00') + assert payment.net_amount == Decimal('485.00') + + # Cleanup + db.session.delete(payment) + db.session.commit() + + def test_edit_payment_get(self, client, test_user, test_payment): + """Test editing payment GET request""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Get edit form + response = client.get(f'/payments/{test_payment.id}/edit') + assert response.status_code == 200 + + def test_edit_payment_post(self, client, test_user, test_payment, app): + """Test editing a payment via POST""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Edit payment + payment_data = { + 'amount': '600.00', + 'currency': 'EUR', + 'payment_date': date.today().strftime('%Y-%m-%d'), + 'method': 'cash', + 'reference': 'UPDATED-REF', + 'status': 'completed', + 'notes': 'Updated payment' + } + + response = client.post(f'/payments/{test_payment.id}/edit', data=payment_data, follow_redirects=True) + assert response.status_code == 200 + + # Verify payment was updated + with app.app_context(): + payment = Payment.query.get(test_payment.id) + assert payment.amount == Decimal('600.00') + assert payment.method == 'cash' + assert payment.reference == 'UPDATED-REF' + + def test_delete_payment(self, client, test_user, test_payment, app): + """Test deleting a payment""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Delete payment + payment_id = test_payment.id + response = client.post(f'/payments/{payment_id}/delete', follow_redirects=True) + assert response.status_code == 200 + + # Verify payment was deleted + with app.app_context(): + payment = Payment.query.get(payment_id) + assert payment is None + + def test_payment_stats_api(self, client, test_user, test_payment): + """Test payment statistics API""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Get payment stats + response = client.get('/api/payments/stats') + assert response.status_code == 200 + + data = response.get_json() + assert 'total_payments' in data + assert 'total_amount' in data + assert 'by_method' in data + assert 'by_status' in data + + def test_create_payment_invalid_amount(self, client, test_user, test_invoice): + """Test creating payment with invalid amount""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Try to create payment with invalid amount + payment_data = { + 'invoice_id': test_invoice.id, + 'amount': '-100.00', # Negative amount + 'currency': 'EUR', + 'payment_date': date.today().strftime('%Y-%m-%d'), + 'status': 'completed' + } + + response = client.post('/payments/create', data=payment_data, follow_redirects=True) + # Should show error message or stay on form + assert response.status_code == 200 + + def test_create_payment_without_invoice(self, client, test_user): + """Test creating payment without selecting invoice""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Try to create payment without invoice + payment_data = { + 'amount': '100.00', + 'currency': 'EUR', + 'payment_date': date.today().strftime('%Y-%m-%d'), + 'status': 'completed' + } + + response = client.post('/payments/create', data=payment_data, follow_redirects=True) + # Should show error or stay on form + assert response.status_code == 200 + + +class TestPaymentFilteringAndSearch: + """Test payment filtering and search functionality""" + + def test_filter_payments_by_status(self, client, test_user, test_payment): + """Test filtering payments by status""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Filter by status + response = client.get('/payments?status=completed') + assert response.status_code == 200 + + def test_filter_payments_by_method(self, client, test_user, test_payment): + """Test filtering payments by method""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Filter by method + response = client.get('/payments?method=bank_transfer') + assert response.status_code == 200 + + def test_filter_payments_by_date_range(self, client, test_user, test_payment): + """Test filtering payments by date range""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Filter by date range + date_from = (date.today() - timedelta(days=7)).strftime('%Y-%m-%d') + date_to = date.today().strftime('%Y-%m-%d') + response = client.get(f'/payments?date_from={date_from}&date_to={date_to}') + assert response.status_code == 200 + + def test_filter_payments_by_invoice(self, client, test_user, test_invoice, test_payment): + """Test filtering payments by invoice""" + with client: + # Login + client.post('/login', data={ + 'username': 'testuser' + }, follow_redirects=True) + + # Filter by invoice + response = client.get(f'/payments?invoice_id={test_invoice.id}') + assert response.status_code == 200 + diff --git a/tests/test_payment_smoke.py b/tests/test_payment_smoke.py new file mode 100644 index 0000000..238d600 --- /dev/null +++ b/tests/test_payment_smoke.py @@ -0,0 +1,425 @@ +"""Smoke tests for Payment tracking feature""" + +import pytest +from datetime import date, timedelta +from decimal import Decimal +from app import db +from app.models import Payment, Invoice, User, Project, Client + + +@pytest.fixture +def setup_payment_test_data(app): + """Setup test data for payment smoke tests""" + with app.app_context(): + # Create user + user = User(username='smoketest_user', email='smoke@example.com') + user.role = 'admin' + db.session.add(user) + + # Create client + client = Client(name='Smoke Test Client', email='smoke_client@example.com') + db.session.add(client) + db.session.flush() + + # Create project + project = Project( + name='Smoke Test Project', + client_id=client.id, + created_by=user.id, + billable=True, + hourly_rate=Decimal('100.00') + ) + db.session.add(project) + db.session.flush() + + # Create invoice + invoice = Invoice( + invoice_number='INV-SMOKE-001', + project_id=project.id, + client_name='Smoke Test Client', + client_id=client.id, + due_date=date.today() + timedelta(days=30), + created_by=user.id + ) + invoice.subtotal = Decimal('1000.00') + invoice.tax_rate = Decimal('21.00') + invoice.tax_amount = Decimal('210.00') + invoice.total_amount = Decimal('1210.00') + db.session.add(invoice) + db.session.commit() + + yield { + 'user': user, + 'client': client, + 'project': project, + 'invoice': invoice + } + + # Cleanup + Payment.query.filter_by(invoice_id=invoice.id).delete() + db.session.delete(invoice) + db.session.delete(project) + db.session.delete(client) + db.session.delete(user) + db.session.commit() + + +class TestPaymentSmokeTests: + """Smoke tests to verify basic payment functionality""" + + def test_payment_model_exists(self): + """Test that Payment model exists and is importable""" + from app.models import Payment + assert Payment is not None + + def test_payment_blueprint_registered(self, app): + """Test that payments blueprint is registered""" + with app.app_context(): + assert 'payments' in app.blueprints + + def test_payment_routes_exist(self, app): + """Test that payment routes are registered""" + with app.app_context(): + rules = [rule.rule for rule in app.url_map.iter_rules()] + assert '/payments' in rules + assert any('/payments/' in rule for rule in rules) + assert '/payments/create' in rules + + def test_payment_database_table_exists(self, app): + """Test that payments table exists in database""" + with app.app_context(): + from sqlalchemy import inspect + inspector = inspect(db.engine) + tables = inspector.get_table_names() + assert 'payments' in tables + + def test_payment_model_columns(self, app): + """Test that payment model has required columns""" + with app.app_context(): + from sqlalchemy import inspect + inspector = inspect(db.engine) + columns = [col['name'] for col in inspector.get_columns('payments')] + + # Required columns + required_columns = [ + 'id', 'invoice_id', 'amount', 'currency', 'payment_date', + 'method', 'reference', 'notes', 'status', 'received_by', + 'gateway_transaction_id', 'gateway_fee', 'net_amount', + 'created_at', 'updated_at' + ] + + for col in required_columns: + assert col in columns, f"Column '{col}' not found in payments table" + + def test_create_and_retrieve_payment(self, app, setup_payment_test_data): + """Test creating and retrieving a payment""" + with app.app_context(): + invoice = setup_payment_test_data['invoice'] + user = setup_payment_test_data['user'] + + # Create payment + payment = Payment( + invoice_id=invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + method='bank_transfer', + status='completed', + received_by=user.id + ) + + db.session.add(payment) + db.session.commit() + payment_id = payment.id + + # Retrieve payment + retrieved_payment = Payment.query.get(payment_id) + assert retrieved_payment is not None + assert retrieved_payment.amount == Decimal('500.00') + assert retrieved_payment.invoice_id == invoice.id + + # Cleanup + db.session.delete(payment) + db.session.commit() + + def test_payment_invoice_relationship(self, app, setup_payment_test_data): + """Test relationship between payment and invoice""" + with app.app_context(): + invoice = setup_payment_test_data['invoice'] + + # Create payment + payment = Payment( + invoice_id=invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + status='completed' + ) + + db.session.add(payment) + db.session.commit() + + # Test relationship + assert payment.invoice is not None + assert payment.invoice.id == invoice.id + + # Refresh invoice to get updated relationships + db.session.refresh(invoice) + assert payment in invoice.payments + + # Cleanup + db.session.delete(payment) + db.session.commit() + + def test_payment_list_page_loads(self, client, setup_payment_test_data): + """Test that payment list page loads""" + with client: + user = setup_payment_test_data['user'] + + # Login + client.post('/login', data={'username': user.username}, follow_redirects=True) + + # Access payments list + response = client.get('/payments') + assert response.status_code == 200 + + def test_payment_create_page_loads(self, client, setup_payment_test_data): + """Test that payment create page loads""" + with client: + user = setup_payment_test_data['user'] + + # Login + client.post('/login', data={'username': user.username}, follow_redirects=True) + + # Access payment create page + response = client.get('/payments/create') + assert response.status_code == 200 + + def test_payment_workflow_end_to_end(self, client, app, setup_payment_test_data): + """Test complete payment workflow from creation to viewing""" + with client: + user = setup_payment_test_data['user'] + invoice = setup_payment_test_data['invoice'] + + # Login + client.post('/login', data={'username': user.username}, follow_redirects=True) + + # Create payment + payment_data = { + 'invoice_id': invoice.id, + 'amount': '500.00', + 'currency': 'EUR', + 'payment_date': date.today().strftime('%Y-%m-%d'), + 'method': 'bank_transfer', + 'reference': 'SMOKE-TEST-001', + 'status': 'completed', + 'notes': 'Smoke test payment' + } + + create_response = client.post('/payments/create', data=payment_data, follow_redirects=True) + assert create_response.status_code == 200 + + # Verify payment was created in database + with app.app_context(): + payment = Payment.query.filter_by(reference='SMOKE-TEST-001').first() + assert payment is not None + payment_id = payment.id + + # View payment + view_response = client.get(f'/payments/{payment_id}') + assert view_response.status_code == 200 + + # Cleanup + db.session.delete(payment) + db.session.commit() + + def test_payment_templates_exist(self, app): + """Test that payment templates exist""" + import os + + template_dir = os.path.join(app.root_path, 'templates', 'payments') + assert os.path.exists(template_dir), "Payments template directory does not exist" + + required_templates = ['list.html', 'create.html', 'edit.html', 'view.html'] + for template in required_templates: + template_path = os.path.join(template_dir, template) + assert os.path.exists(template_path), f"Template {template} does not exist" + + def test_payment_model_methods(self, app, setup_payment_test_data): + """Test that payment model has required methods""" + with app.app_context(): + invoice = setup_payment_test_data['invoice'] + + payment = Payment( + invoice_id=invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + gateway_fee=Decimal('15.00'), + status='completed' + ) + + # Test calculate_net_amount method + assert hasattr(payment, 'calculate_net_amount') + payment.calculate_net_amount() + assert payment.net_amount == Decimal('485.00') + + # Test to_dict method + assert hasattr(payment, 'to_dict') + payment_dict = payment.to_dict() + assert isinstance(payment_dict, dict) + assert 'amount' in payment_dict + assert 'invoice_id' in payment_dict + + def test_payment_filter_functionality(self, client, app, setup_payment_test_data): + """Test payment filtering functionality""" + with client: + user = setup_payment_test_data['user'] + invoice = setup_payment_test_data['invoice'] + + # Login + client.post('/login', data={'username': user.username}, follow_redirects=True) + + # Create test payments with different statuses + with app.app_context(): + payment1 = Payment( + invoice_id=invoice.id, + amount=Decimal('100.00'), + currency='EUR', + payment_date=date.today(), + method='cash', + status='completed' + ) + payment2 = Payment( + invoice_id=invoice.id, + amount=Decimal('200.00'), + currency='EUR', + payment_date=date.today(), + method='bank_transfer', + status='pending' + ) + + db.session.add_all([payment1, payment2]) + db.session.commit() + + # Test filter by status + response = client.get('/payments?status=completed') + assert response.status_code == 200 + + # Test filter by method + response = client.get('/payments?method=cash') + assert response.status_code == 200 + + # Cleanup + db.session.delete(payment1) + db.session.delete(payment2) + db.session.commit() + + def test_invoice_shows_payment_history(self, client, app, setup_payment_test_data): + """Test that invoice view shows payment history""" + with client: + user = setup_payment_test_data['user'] + invoice = setup_payment_test_data['invoice'] + + # Login + client.post('/login', data={'username': user.username}, follow_redirects=True) + + # Create payment + with app.app_context(): + payment = Payment( + invoice_id=invoice.id, + amount=Decimal('500.00'), + currency='EUR', + payment_date=date.today(), + status='completed' + ) + db.session.add(payment) + db.session.commit() + + # View invoice + response = client.get(f'/invoices/{invoice.id}') + assert response.status_code == 200 + # Check if payment history section exists in response + assert b'Payment History' in response.data or b'payment' in response.data.lower() + + # Cleanup + db.session.delete(payment) + db.session.commit() + + +class TestPaymentFeatureCompleteness: + """Tests to ensure payment feature is complete""" + + def test_migration_exists(self): + """Test that payment migration file exists""" + import os + + migration_dir = os.path.join(os.path.dirname(__file__), '..', 'migrations', 'versions') + migration_files = os.listdir(migration_dir) + + # Check for payment-related migration + payment_migrations = [f for f in migration_files if 'payment' in f.lower()] + assert len(payment_migrations) > 0, "No payment migration found" + + def test_payment_api_endpoint_exists(self, app): + """Test that payment API endpoints exist""" + with app.app_context(): + rules = [rule.rule for rule in app.url_map.iter_rules()] + assert any('payments' in rule and 'api' in rule for rule in rules) + + def test_all_crud_operations_work(self, client, app, setup_payment_test_data): + """Test that all CRUD operations for payments work""" + with client: + user = setup_payment_test_data['user'] + invoice = setup_payment_test_data['invoice'] + + # Login + client.post('/login', data={'username': user.username}, follow_redirects=True) + + # CREATE + payment_data = { + 'invoice_id': invoice.id, + 'amount': '300.00', + 'currency': 'EUR', + 'payment_date': date.today().strftime('%Y-%m-%d'), + 'method': 'cash', + 'status': 'completed' + } + + create_response = client.post('/payments/create', data=payment_data, follow_redirects=True) + assert create_response.status_code == 200 + + with app.app_context(): + payment = Payment.query.filter_by(invoice_id=invoice.id, method='cash').first() + assert payment is not None + payment_id = payment.id + + # READ + read_response = client.get(f'/payments/{payment_id}') + assert read_response.status_code == 200 + + # UPDATE + update_data = { + 'amount': '350.00', + 'currency': 'EUR', + 'payment_date': date.today().strftime('%Y-%m-%d'), + 'method': 'bank_transfer', + 'status': 'completed' + } + + update_response = client.post(f'/payments/{payment_id}/edit', data=update_data, follow_redirects=True) + assert update_response.status_code == 200 + + # Verify update + db.session.refresh(payment) + assert payment.amount == Decimal('350.00') + assert payment.method == 'bank_transfer' + + # DELETE + delete_response = client.post(f'/payments/{payment_id}/delete', follow_redirects=True) + assert delete_response.status_code == 200 + + # Verify deletion + deleted_payment = Payment.query.get(payment_id) + assert deleted_payment is None +