mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-05 11:09:55 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
481
app/routes/payments.py
Normal 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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
202
app/templates/payments/create.html
Normal file
202
app/templates/payments/create.html
Normal 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 %}
|
||||
|
||||
132
app/templates/payments/edit.html
Normal file
132
app/templates/payments/edit.html
Normal 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 %}
|
||||
|
||||
151
app/templates/payments/list.html
Normal file
151
app/templates/payments/list.html
Normal 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 %}
|
||||
|
||||
261
app/templates/payments/view.html
Normal file
261
app/templates/payments/view.html
Normal 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 %}
|
||||
|
||||
@@ -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
407
docs/PAYMENT_TRACKING.md
Normal 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
|
||||
|
||||
120
migrations/versions/035_enhance_payments_table.py
Normal file
120
migrations/versions/035_enhance_payments_table.py
Normal 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
369
tests/test_payment_model.py
Normal 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()
|
||||
|
||||
423
tests/test_payment_routes.py
Normal file
423
tests/test_payment_routes.py
Normal 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
425
tests/test_payment_smoke.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user