feat(payments): add analytics integration and improve UI consistency

## Payment Analytics Integration
- Add 5 new API endpoints for payment metrics:
  - /api/analytics/payments-over-time - trend visualization
  - /api/analytics/payments-by-status - status distribution
  - /api/analytics/payments-by-method - method breakdown
  - /api/analytics/payment-summary - statistics with period comparison
  - /api/analytics/revenue-vs-payments - collection rate tracking
- Integrate payment data into analytics dashboard with 4 new charts
- Add payment metrics to reports page (total, count, fees, net received)
- Update summary endpoint to include payment statistics

## UI/UX Improvements
- Standardize form styling across all payment templates
  - Replace inconsistent Tailwind classes with form-input utility
  - Update card backgrounds to use card-light/card-dark
  - Fix label spacing to match application patterns
  - Ensure consistent border colors and backgrounds
- Replace browser confirm() with system-wide modal for payment deletion
  - Consistent danger variant with warning icon
  - Keyboard support (Enter/Escape)
  - Dark mode compatible
  - Clear messaging about impact on invoice status

## Technical Changes
- Import Payment and Invoice models in analytics and reports routes
- Add proper admin/user scoping for payment queries
- Maintain responsive design across all new components

Closes payment tracking phase 2 (analytics & polish)
This commit is contained in:
Dries Peeters
2025-10-27 13:38:07 +01:00
parent f4d705da08
commit 8d4ec0e25f
18 changed files with 3702 additions and 9 deletions

View File

@@ -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)

View File

@@ -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"<Payment {self.amount} for invoice {self.invoice_id}>"
return f"<Payment {self.amount} {self.currency or 'EUR'} for invoice {self.invoice_id}>"
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):

View File

@@ -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]
})

481
app/routes/payments.py Normal file
View File

@@ -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/<int:payment_id>')
@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/<int:payment_id>/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/<int:payment_id>/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()

View File

@@ -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()

View File

@@ -126,6 +126,42 @@
</div>
</div>
<!-- Charts: Payment Analytics -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-money-bill-wave text-green-600 mr-2"></i>{{ _('Payments Over Time') }}</h3>
<div class="relative h-[300px]"><canvas id="paymentsOverTimeChart"></canvas></div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-chart-pie text-emerald-600 mr-2"></i>{{ _('Payment Status') }}</h3>
<div class="relative h-[300px]"><canvas id="paymentStatusChart"></canvas></div>
</div>
</div>
<!-- Charts: Payment Methods & Revenue Comparison -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-credit-card text-blue-600 mr-2"></i>{{ _('Payment Methods') }}</h3>
<div class="relative h-[300px]"><canvas id="paymentMethodChart"></canvas></div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-balance-scale text-indigo-600 mr-2"></i>{{ _('Revenue vs Payments') }}</h3>
<div class="relative h-[300px]">
<canvas id="revenueVsPaymentsChart"></canvas>
</div>
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Potential Revenue') }}</div>
<div class="text-lg font-semibold text-amber-600" id="potentialRevenue">-</div>
</div>
<div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Collection Rate') }}</div>
<div class="text-lg font-semibold text-green-600" id="collectionRate">-</div>
</div>
</div>
</div>
</div>
<!-- Charts: Hours by Project & Weekly Trends -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
@@ -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}`);

View File

@@ -100,7 +100,7 @@
<nav class="flex-1">
{% set ep = request.endpoint or '' %}
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('expenses.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') %}
<div class="flex items-center justify-between mb-4">
@@ -170,6 +170,7 @@
<ul id="financeDropdown" class="{% if not finance_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_reports = ep.startswith('reports.') %}
{% set nav_active_invoices = ep.startswith('invoices.') %}
{% set nav_active_payments = ep.startswith('payments.') %}
{% set nav_active_expenses = ep.startswith('expenses.') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a>
@@ -177,6 +178,9 @@
<li>
<a class="block px-2 py-1 rounded {% if nav_active_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoices.list_invoices') }}">{{ _('Invoices') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_payments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('payments.list_payments') }}">{{ _('Payments') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_expenses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expenses.list_expenses') }}">{{ _('Expenses') }}</a>
</li>

View File

@@ -90,11 +90,92 @@
<span>Tax ({{ "%.2f"|format(invoice.tax_rate) }}%)</span>
<span>{{ "%.2f"|format(invoice.tax_amount) }} {{ invoice.currency_code }}</span>
</div>
<div class="flex justify-between font-bold text-lg">
<div class="flex justify-between font-bold text-lg border-t border-gray-300 dark:border-gray-600 pt-2">
<span>Total</span>
<span>{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</span>
</div>
<div class="flex justify-between text-green-600 dark:text-green-400 mt-2">
<span>Amount Paid</span>
<span>{{ "%.2f"|format(invoice.amount_paid or 0) }} {{ invoice.currency_code }}</span>
</div>
<div class="flex justify-between font-semibold text-red-600 dark:text-red-400 border-t border-gray-300 dark:border-gray-600 pt-2 mt-2">
<span>Outstanding</span>
<span>{{ "%.2f"|format(invoice.outstanding_amount) }} {{ invoice.currency_code }}</span>
</div>
</div>
</div>
<!-- Payment History -->
{% if invoice.payments.count() > 0 %}
<div class="mt-8 border-t border-gray-300 dark:border-gray-600 pt-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Payment History</h2>
<a href="{{ url_for('payments.create_payment', invoice_id=invoice.id) }}" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg text-sm">
<i class="fas fa-plus mr-2"></i>Add Payment
</a>
</div>
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-300 dark:border-gray-600">
<th class="p-2">Date</th>
<th class="p-2">Amount</th>
<th class="p-2">Method</th>
<th class="p-2">Reference</th>
<th class="p-2">Status</th>
<th class="p-2">Actions</th>
</tr>
</thead>
<tbody>
{% for payment in invoice.payments.order_by('payment_date desc, created_at desc') %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="p-2">{{ payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else 'N/A' }}</td>
<td class="p-2 font-semibold text-green-600 dark:text-green-400">
{{ "%.2f"|format(payment.amount) }} {{ payment.currency or invoice.currency_code }}
{% if payment.gateway_fee %}
<span class="text-xs text-gray-500 dark:text-gray-400">(Fee: {{ "%.2f"|format(payment.gateway_fee) }})</span>
{% endif %}
</td>
<td class="p-2">{{ payment.method or 'N/A' }}</td>
<td class="p-2 text-sm">{{ payment.reference or '-' }}</td>
<td class="p-2">
{% if payment.status == 'completed' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
Completed
</span>
{% elif payment.status == 'pending' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
Pending
</span>
{% elif payment.status == 'failed' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100">
Failed
</span>
{% elif payment.status == 'refunded' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-gray-100">
Refunded
</span>
{% endif %}
</td>
<td class="p-2">
<a href="{{ url_for('payments.view_payment', payment_id=payment.id) }}" class="text-primary hover:text-primary-dark text-sm">
View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="mt-8 border-t border-gray-300 dark:border-gray-600 pt-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Payment History</h2>
<a href="{{ url_for('payments.create_payment', invoice_id=invoice.id) }}" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg text-sm">
<i class="fas fa-plus mr-2"></i>Record First Payment
</a>
</div>
<p class="text-gray-500 dark:text-gray-400 text-center py-4">No payments recorded yet.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,202 @@
{% extends "base.html" %}
{% block content %}
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Record New Payment</h1>
<a href="{{ url_for('payments.list_payments') }}" class="text-primary hover:text-primary-dark">
<i class="fas fa-arrow-left mr-2"></i>Back to Payments
</a>
</div>
<!-- Form -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6">
<form method="POST" action="{{ url_for('payments.create_payment') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Invoice Selection -->
<div class="md:col-span-2">
<label for="invoice_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Invoice <span class="text-red-500">*</span>
</label>
<select name="invoice_id" id="invoice_id" required
class="form-input"
onchange="updateInvoiceDetails(this)">
<option value="">Select an invoice</option>
{% for invoice in invoices %}
<option value="{{ invoice.id }}"
data-total="{{ invoice.total_amount }}"
data-paid="{{ invoice.amount_paid or 0 }}"
data-outstanding="{{ invoice.outstanding_amount }}"
data-currency="{{ invoice.currency_code }}"
{% if selected_invoice and selected_invoice.id == invoice.id %}selected{% endif %}>
{{ invoice.invoice_number }} - {{ invoice.client_name }} (Outstanding: {{ invoice.outstanding_amount }} {{ invoice.currency_code }})
</option>
{% endfor %}
</select>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select the invoice for which you're recording this payment</p>
</div>
<!-- Invoice Details Display -->
<div id="invoice-details" class="md:col-span-2 bg-background-light dark:bg-background-dark p-4 rounded-lg" style="display: none;">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Invoice Details</h3>
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-gray-600 dark:text-gray-400">Total Amount:</span>
<span id="invoice-total" class="font-semibold text-gray-900 dark:text-gray-100 ml-2">-</span>
</div>
<div>
<span class="text-gray-600 dark:text-gray-400">Already Paid:</span>
<span id="invoice-paid" class="font-semibold text-green-600 dark:text-green-400 ml-2">-</span>
</div>
<div>
<span class="text-gray-600 dark:text-gray-400">Outstanding:</span>
<span id="invoice-outstanding" class="font-semibold text-red-600 dark:text-red-400 ml-2">-</span>
</div>
</div>
</div>
<!-- Amount -->
<div>
<label for="amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Amount <span class="text-red-500">*</span>
</label>
<input type="number" name="amount" id="amount" step="0.01" min="0.01" required
value="{{ selected_invoice.outstanding_amount if selected_invoice else '' }}"
class="form-input">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Payment amount</p>
</div>
<!-- Currency -->
<div>
<label for="currency" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Currency</label>
<input type="text" name="currency" id="currency" maxlength="3"
value="{{ selected_invoice.currency_code if selected_invoice else 'EUR' }}"
class="form-input">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">3-letter currency code (e.g., EUR, USD)</p>
</div>
<!-- Payment Date -->
<div>
<label for="payment_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Payment Date <span class="text-red-500">*</span>
</label>
<input type="date" name="payment_date" id="payment_date" required
value="{{ today }}"
class="form-input">
</div>
<!-- Payment Method -->
<div>
<label for="method" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Payment Method</label>
<select name="method" id="method"
class="form-input">
<option value="">Select method</option>
<option value="bank_transfer">Bank Transfer</option>
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="credit_card">Credit Card</option>
<option value="debit_card">Debit Card</option>
<option value="paypal">PayPal</option>
<option value="stripe">Stripe</option>
<option value="wire_transfer">Wire Transfer</option>
<option value="other">Other</option>
</select>
</div>
<!-- Status -->
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select name="status" id="status"
class="form-input">
<option value="completed" selected>Completed</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="refunded">Refunded</option>
</select>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Payment status</p>
</div>
<!-- Reference -->
<div>
<label for="reference" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Reference/Transaction ID</label>
<input type="text" name="reference" id="reference" maxlength="100"
class="form-input">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Check number, transaction ID, etc.</p>
</div>
<!-- Gateway Transaction ID -->
<div>
<label for="gateway_transaction_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Gateway Transaction ID</label>
<input type="text" name="gateway_transaction_id" id="gateway_transaction_id" maxlength="255"
class="form-input">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Payment gateway transaction ID</p>
</div>
<!-- Gateway Fee -->
<div>
<label for="gateway_fee" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Gateway Fee</label>
<input type="number" name="gateway_fee" id="gateway_fee" step="0.01" min="0"
class="form-input">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Transaction or processing fee</p>
</div>
<!-- Notes -->
<div class="md:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<textarea name="notes" id="notes" rows="3"
class="form-input"></textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Additional payment notes</p>
</div>
</div>
<!-- Form Actions -->
<div class="mt-6 flex justify-end space-x-3">
<a href="{{ url_for('payments.list_payments') }}" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</a>
<button type="submit" class="px-4 py-2 bg-primary hover:bg-primary-dark text-white rounded-md transition-colors">
<i class="fas fa-save mr-2"></i>Record Payment
</button>
</div>
</form>
</div>
</div>
<script>
function updateInvoiceDetails(select) {
const selectedOption = select.options[select.selectedIndex];
const detailsDiv = document.getElementById('invoice-details');
if (selectedOption.value) {
const total = selectedOption.dataset.total;
const paid = selectedOption.dataset.paid;
const outstanding = selectedOption.dataset.outstanding;
const currency = selectedOption.dataset.currency;
document.getElementById('invoice-total').textContent = `${total} ${currency}`;
document.getElementById('invoice-paid').textContent = `${paid} ${currency}`;
document.getElementById('invoice-outstanding').textContent = `${outstanding} ${currency}`;
// Update amount field with outstanding amount
document.getElementById('amount').value = outstanding;
document.getElementById('currency').value = currency;
detailsDiv.style.display = 'block';
} else {
detailsDiv.style.display = 'none';
}
}
// Initialize on page load if an invoice is pre-selected
document.addEventListener('DOMContentLoaded', function() {
const invoiceSelect = document.getElementById('invoice_id');
if (invoiceSelect.value) {
updateInvoiceDetails(invoiceSelect);
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block content %}
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Edit Payment #{{ payment.id }}</h1>
<div class="space-x-2">
<a href="{{ url_for('payments.view_payment', payment_id=payment.id) }}" class="text-primary hover:text-primary-dark">
<i class="fas fa-arrow-left mr-2"></i>Back to Payment
</a>
</div>
</div>
<!-- Form -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6">
<form method="POST" action="{{ url_for('payments.edit_payment', payment_id=payment.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Invoice Info (Read-only) -->
<div class="mb-6 p-4 bg-background-light dark:bg-background-dark rounded-lg">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Invoice</h3>
<a href="{{ url_for('invoices.view_invoice', invoice_id=payment.invoice_id) }}" class="text-primary hover:text-primary-dark font-semibold">
{{ payment.invoice.invoice_number }} - {{ payment.invoice.client_name }}
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Amount -->
<div>
<label for="amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Amount <span class="text-red-500">*</span>
</label>
<input type="number" name="amount" id="amount" step="0.01" min="0.01" required
value="{{ payment.amount }}"
class="form-input">
</div>
<!-- Currency -->
<div>
<label for="currency" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Currency</label>
<input type="text" name="currency" id="currency" maxlength="3"
value="{{ payment.currency or 'EUR' }}"
class="form-input">
</div>
<!-- Payment Date -->
<div>
<label for="payment_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Payment Date <span class="text-red-500">*</span>
</label>
<input type="date" name="payment_date" id="payment_date" required
value="{{ payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else '' }}"
class="form-input">
</div>
<!-- Payment Method -->
<div>
<label for="method" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Payment Method</label>
<select name="method" id="method"
class="form-input">
<option value="">Select method</option>
<option value="bank_transfer" {% if payment.method == 'bank_transfer' %}selected{% endif %}>Bank Transfer</option>
<option value="cash" {% if payment.method == 'cash' %}selected{% endif %}>Cash</option>
<option value="check" {% if payment.method == 'check' %}selected{% endif %}>Check</option>
<option value="credit_card" {% if payment.method == 'credit_card' %}selected{% endif %}>Credit Card</option>
<option value="debit_card" {% if payment.method == 'debit_card' %}selected{% endif %}>Debit Card</option>
<option value="paypal" {% if payment.method == 'paypal' %}selected{% endif %}>PayPal</option>
<option value="stripe" {% if payment.method == 'stripe' %}selected{% endif %}>Stripe</option>
<option value="wire_transfer" {% if payment.method == 'wire_transfer' %}selected{% endif %}>Wire Transfer</option>
<option value="other" {% if payment.method == 'other' %}selected{% endif %}>Other</option>
</select>
</div>
<!-- Status -->
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select name="status" id="status"
class="form-input">
<option value="completed" {% if payment.status == 'completed' %}selected{% endif %}>Completed</option>
<option value="pending" {% if payment.status == 'pending' %}selected{% endif %}>Pending</option>
<option value="failed" {% if payment.status == 'failed' %}selected{% endif %}>Failed</option>
<option value="refunded" {% if payment.status == 'refunded' %}selected{% endif %}>Refunded</option>
</select>
</div>
<!-- Reference -->
<div>
<label for="reference" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Reference/Transaction ID</label>
<input type="text" name="reference" id="reference" maxlength="100"
value="{{ payment.reference or '' }}"
class="form-input">
</div>
<!-- Gateway Transaction ID -->
<div>
<label for="gateway_transaction_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Gateway Transaction ID</label>
<input type="text" name="gateway_transaction_id" id="gateway_transaction_id" maxlength="255"
value="{{ payment.gateway_transaction_id or '' }}"
class="form-input">
</div>
<!-- Gateway Fee -->
<div>
<label for="gateway_fee" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Gateway Fee</label>
<input type="number" name="gateway_fee" id="gateway_fee" step="0.01" min="0"
value="{{ payment.gateway_fee or '' }}"
class="form-input">
</div>
<!-- Notes -->
<div class="md:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<textarea name="notes" id="notes" rows="3"
class="form-input">{{ payment.notes or '' }}</textarea>
</div>
</div>
<!-- Form Actions -->
<div class="mt-6 flex justify-end space-x-3">
<a href="{{ url_for('payments.view_payment', payment_id=payment.id) }}" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</a>
<button type="submit" class="px-4 py-2 bg-primary hover:bg-primary-dark text-white rounded-md transition-colors">
<i class="fas fa-save mr-2"></i>Update Payment
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block content %}
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Payments</h1>
<a href="{{ url_for('payments.create_payment') }}" class="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-plus mr-2"></i>Record Payment
</a>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Payments</h3>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">{{ summary.total_payments }}</p>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Amount</h3>
<p class="text-2xl font-bold text-green-600 dark:text-green-400 mt-2">€{{ "%.2f"|format(summary.total_amount) }}</p>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Completed</h3>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-2">{{ summary.completed_count }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">€{{ "%.2f"|format(summary.completed_amount) }}</p>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Gateway Fees</h3>
<p class="text-2xl font-bold text-red-600 dark:text-red-400 mt-2">€{{ "%.2f"|format(summary.total_fees) }}</p>
</div>
</div>
<!-- Filters -->
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status" id="status" class="form-input">
<option value="">All</option>
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>Completed</option>
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>Pending</option>
<option value="failed" {% if filters.status == 'failed' %}selected{% endif %}>Failed</option>
<option value="refunded" {% if filters.status == 'refunded' %}selected{% endif %}>Refunded</option>
</select>
</div>
<div>
<label for="method" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Payment Method</label>
<select name="method" id="method" class="form-input">
<option value="">All</option>
{% for method in payment_methods %}
<option value="{{ method }}" {% if filters.method == method %}selected{% endif %}>{{ method }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="date_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">From Date</label>
<input type="date" name="date_from" id="date_from" value="{{ filters.date_from }}" class="form-input">
</div>
<div>
<label for="date_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">To Date</label>
<input type="date" name="date_to" id="date_to" value="{{ filters.date_to }}" class="form-input">
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded-lg mr-2">Filter</button>
<a href="{{ url_for('payments.list_payments') }}" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg">Clear</a>
</div>
</form>
</div>
<!-- Payments Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
{% if payments %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Invoice</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Method</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for payment in payments %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">#{{ payment.id }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<a href="{{ url_for('invoices.view_invoice', invoice_id=payment.invoice_id) }}" class="text-primary hover:text-primary-dark">
{{ payment.invoice.invoice_number }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-semibold text-green-600 dark:text-green-400">
{{ payment.amount }} {{ payment.currency or 'EUR' }}
</span>
{% if payment.gateway_fee %}
<span class="text-xs text-gray-500 dark:text-gray-400 block">
Fee: {{ payment.gateway_fee }} {{ payment.currency or 'EUR' }}
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else 'N/A' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ payment.method or 'N/A' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if payment.status == 'completed' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
Completed
</span>
{% elif payment.status == 'pending' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
Pending
</span>
{% elif payment.status == 'failed' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100">
Failed
</span>
{% elif payment.status == 'refunded' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-gray-100">
Refunded
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ url_for('payments.view_payment', payment_id=payment.id) }}" class="text-primary hover:text-primary-dark mr-3">View</a>
<a href="{{ url_for('payments.edit_payment', payment_id=payment.id) }}" class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 mr-3">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No payments found.</p>
<a href="{{ url_for('payments.create_payment') }}" class="text-primary hover:text-primary-dark mt-2 inline-block">Record your first payment</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,261 @@
{% extends "base.html" %}
{% block content %}
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Payment #{{ payment.id }}</h1>
<div class="space-x-2">
<a href="{{ url_for('payments.list_payments') }}" class="text-primary hover:text-primary-dark">
<i class="fas fa-arrow-left mr-2"></i>Back to Payments
</a>
<a href="{{ url_for('payments.edit_payment', payment_id=payment.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg">
<i class="fas fa-edit mr-2"></i>Edit
</a>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Payment Info -->
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark rounded-lg shadow p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Payment Details</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Amount -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Amount</label>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ payment.amount }} {{ payment.currency or 'EUR' }}
</p>
</div>
<!-- Status -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Status</label>
<div class="mt-2">
{% if payment.status == 'completed' %}
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
<i class="fas fa-check-circle mr-1"></i>Completed
</span>
{% elif payment.status == 'pending' %}
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
<i class="fas fa-clock mr-1"></i>Pending
</span>
{% elif payment.status == 'failed' %}
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100">
<i class="fas fa-times-circle mr-1"></i>Failed
</span>
{% elif payment.status == 'refunded' %}
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-gray-100">
<i class="fas fa-undo mr-1"></i>Refunded
</span>
{% endif %}
</div>
</div>
<!-- Payment Date -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Payment Date</label>
<p class="text-lg text-gray-900 dark:text-gray-100">
{{ payment.payment_date.strftime('%B %d, %Y') if payment.payment_date else 'N/A' }}
</p>
</div>
<!-- Payment Method -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Payment Method</label>
<p class="text-lg text-gray-900 dark:text-gray-100">
{% if payment.method %}
<i class="fas fa-credit-card mr-2 text-gray-500"></i>{{ payment.method }}
{% else %}
N/A
{% endif %}
</p>
</div>
{% if payment.reference %}
<!-- Reference -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Reference</label>
<p class="text-lg text-gray-900 dark:text-gray-100">{{ payment.reference }}</p>
</div>
{% endif %}
{% if payment.gateway_transaction_id %}
<!-- Gateway Transaction ID -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Gateway Transaction ID</label>
<p class="text-sm text-gray-900 dark:text-gray-100 font-mono">{{ payment.gateway_transaction_id }}</p>
</div>
{% endif %}
{% if payment.gateway_fee %}
<!-- Gateway Fee -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Gateway Fee</label>
<p class="text-lg text-red-600 dark:text-red-400">
{{ payment.gateway_fee }} {{ payment.currency or 'EUR' }}
</p>
</div>
<!-- Net Amount -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Net Amount</label>
<p class="text-lg text-green-600 dark:text-green-400">
{{ payment.net_amount or payment.amount }} {{ payment.currency or 'EUR' }}
</p>
</div>
{% endif %}
{% if payment.received_by %}
<!-- Received By -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Received By</label>
<p class="text-lg text-gray-900 dark:text-gray-100">
{{ payment.receiver.username if payment.receiver else 'Unknown' }}
</p>
</div>
{% endif %}
<!-- Created At -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Created</label>
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ payment.created_at.strftime('%B %d, %Y at %I:%M %p') if payment.created_at else 'N/A' }}
</p>
</div>
<!-- Updated At -->
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Last Updated</label>
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ payment.updated_at.strftime('%B %d, %Y at %I:%M %p') if payment.updated_at else 'N/A' }}
</p>
</div>
</div>
{% if payment.notes %}
<!-- Notes -->
<div class="mt-6 pt-6 border-t border-border-light dark:border-border-dark">
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Notes</label>
<div class="bg-background-light dark:bg-background-dark p-4 rounded-lg">
<p class="text-gray-900 dark:text-gray-100 whitespace-pre-wrap">{{ payment.notes }}</p>
</div>
</div>
{% endif %}
</div>
<!-- Invoice Info Sidebar -->
<div class="lg:col-span-1">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Related Invoice</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Invoice Number</label>
<a href="{{ url_for('invoices.view_invoice', invoice_id=payment.invoice_id) }}"
class="text-lg font-semibold text-primary hover:text-primary-dark">
{{ payment.invoice.invoice_number }}
</a>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Client</label>
<p class="text-gray-900 dark:text-gray-100">{{ payment.invoice.client_name }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Total Amount</label>
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ payment.invoice.total_amount }} {{ payment.invoice.currency_code }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Amount Paid</label>
<p class="text-lg font-semibold text-green-600 dark:text-green-400">
{{ payment.invoice.amount_paid or 0 }} {{ payment.invoice.currency_code }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Outstanding</label>
<p class="text-lg font-semibold text-red-600 dark:text-red-400">
{{ payment.invoice.outstanding_amount }} {{ payment.invoice.currency_code }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Payment Status</label>
{% if payment.invoice.payment_status == 'fully_paid' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
Fully Paid
</span>
{% elif payment.invoice.payment_status == 'partially_paid' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
Partially Paid
</span>
{% else %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100">
Unpaid
</span>
{% endif %}
</div>
<div class="pt-4 border-t border-border-light dark:border-border-dark">
<a href="{{ url_for('invoices.view_invoice', invoice_id=payment.invoice_id) }}"
class="block w-full text-center bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-file-invoice mr-2"></i>View Invoice
</a>
</div>
</div>
</div>
<!-- Actions -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6 mt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Actions</h3>
<div class="space-y-2">
<a href="{{ url_for('payments.edit_payment', payment_id=payment.id) }}"
class="block w-full text-center bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-edit mr-2"></i>Edit Payment
</a>
<form id="deletePaymentForm" method="POST" action="{{ url_for('payments.delete_payment', payment_id=payment.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" onclick="confirmDeletePayment()" class="block w-full text-center bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-trash mr-2"></i>Delete Payment
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<script>
function confirmDeletePayment() {
const message = 'Are you sure you want to delete this payment? This will affect the invoice payment status and cannot be undone.';
if (window.showConfirm) {
window.showConfirm(message, {
title: 'Delete Payment',
confirmText: 'Delete',
cancelText: 'Cancel',
variant: 'danger'
}).then(function(confirmed) {
if (confirmed) {
document.getElementById('deletePaymentForm').submit();
}
});
} else {
// Fallback if showConfirm is not available
if (confirm(message)) {
document.getElementById('deletePaymentForm').submit();
}
}
}
</script>
{% endblock %}

View File

@@ -13,6 +13,41 @@
{{ info_card("Active Users", summary.total_users, "Currently active") }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-money-bill-wave text-green-600 text-xl"></i>
</div>
<div class="text-2xl font-semibold text-green-600">€{{ "%.2f"|format(summary.total_payments) }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Total Payments</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">Last 30 days</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-receipt text-blue-600 text-xl"></i>
</div>
<div class="text-2xl font-semibold text-blue-600">{{ summary.payment_count }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Payments Received</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">Last 30 days</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-credit-card text-amber-600 text-xl"></i>
</div>
<div class="text-2xl font-semibold text-amber-600">€{{ "%.2f"|format(summary.payment_fees) }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Gateway Fees</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">Last 30 days</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-chart-line text-emerald-600 text-xl"></i>
</div>
<div class="text-2xl font-semibold text-emerald-600">€{{ "%.2f"|format(summary.total_payments - summary.payment_fees) }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Net Received</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">After fees</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Report Types</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">

407
docs/PAYMENT_TRACKING.md Normal file
View File

@@ -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/<payment_id>
```
### 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/<payment_id>/edit
POST /payments/<payment_id>/edit
```
### Delete Payment
```
POST /payments/<payment_id>/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

View File

@@ -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

369
tests/test_payment_model.py Normal file
View File

@@ -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()

View File

@@ -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

425
tests/test_payment_smoke.py Normal file
View File

@@ -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/<int:payment_id>' 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