Merge pull request #251 from DRYTRIX/Feat-InvoiceUpdate

feat: Add recurring invoices and email integration with template mana…
This commit is contained in:
Dries Peeters
2025-11-13 09:26:18 +01:00
committed by GitHub
27 changed files with 2461 additions and 8 deletions

View File

@@ -759,6 +759,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.recurring_invoices import recurring_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
@@ -801,6 +802,7 @@ def create_app(config=None):
app.register_blueprint(analytics_bp)
app.register_blueprint(tasks_bp)
app.register_blueprint(invoices_bp)
app.register_blueprint(recurring_invoices_bp)
app.register_blueprint(payments_bp)
app.register_blueprint(clients_bp)
app.register_blueprint(client_notes_bp)

View File

@@ -36,6 +36,8 @@ from .budget_alert import BudgetAlert
from .import_export import DataImport, DataExport
from .invoice_pdf_template import InvoicePDFTemplate
from .audit_log import AuditLog
from .recurring_invoice import RecurringInvoice
from .invoice_email import InvoiceEmail
__all__ = [
"User",
@@ -79,4 +81,6 @@ __all__ = [
"InvoicePDFTemplate",
"ClientPrepaidConsumption",
"AuditLog",
"RecurringInvoice",
"InvoiceEmail",
]

View File

@@ -28,6 +28,7 @@ class Invoice(db.Model):
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
template_id = db.Column(db.Integer, db.ForeignKey('invoice_templates.id'), nullable=True, index=True)
recurring_invoice_id = db.Column(db.Integer, db.ForeignKey('recurring_invoices.id'), nullable=True, index=True)
# Notes and terms
notes = db.Column(db.Text, nullable=True)

View File

@@ -0,0 +1,94 @@
from datetime import datetime
from app import db
class InvoiceEmail(db.Model):
"""Model for tracking invoice emails sent to clients"""
__tablename__ = 'invoice_emails'
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True)
# Email details
recipient_email = db.Column(db.String(200), nullable=False)
subject = db.Column(db.String(500), nullable=False)
sent_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
sent_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Tracking
opened_at = db.Column(db.DateTime, nullable=True) # When email was opened (if tracked)
opened_count = db.Column(db.Integer, nullable=False, default=0) # Number of times opened
last_opened_at = db.Column(db.DateTime, nullable=True) # Last time email was opened
# Payment tracking
paid_at = db.Column(db.DateTime, nullable=True) # When invoice was marked as paid (if after email)
# Status
status = db.Column(db.String(20), nullable=False, default='sent') # 'sent', 'opened', 'paid', 'bounced', 'failed'
# Error tracking
error_message = db.Column(db.Text, nullable=True) # Error message if send failed
# 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
invoice = db.relationship('Invoice', backref='email_records')
sender = db.relationship('User', backref='sent_invoice_emails')
def __init__(self, invoice_id, recipient_email, subject, sent_by, **kwargs):
self.invoice_id = invoice_id
self.recipient_email = recipient_email
self.subject = subject
self.sent_by = sent_by
self.status = kwargs.get('status', 'sent')
self.error_message = kwargs.get('error_message')
def __repr__(self):
return f'<InvoiceEmail {self.invoice_id} -> {self.recipient_email} ({self.status})>'
def mark_opened(self):
"""Mark email as opened"""
if not self.opened_at:
self.opened_at = datetime.utcnow()
self.last_opened_at = datetime.utcnow()
self.opened_count += 1
if self.status == 'sent':
self.status = 'opened'
def mark_paid(self):
"""Mark invoice as paid (after email was sent)"""
if not self.paid_at:
self.paid_at = datetime.utcnow()
self.status = 'paid'
def mark_failed(self, error_message):
"""Mark email send as failed"""
self.status = 'failed'
self.error_message = error_message
def mark_bounced(self):
"""Mark email as bounced"""
self.status = 'bounced'
def to_dict(self):
"""Convert invoice email to dictionary"""
return {
'id': self.id,
'invoice_id': self.invoice_id,
'recipient_email': self.recipient_email,
'subject': self.subject,
'sent_at': self.sent_at.isoformat() if self.sent_at else None,
'sent_by': self.sent_by,
'opened_at': self.opened_at.isoformat() if self.opened_at else None,
'opened_count': self.opened_count,
'last_opened_at': self.last_opened_at.isoformat() if self.last_opened_at else None,
'paid_at': self.paid_at.isoformat() if self.paid_at else None,
'status': self.status,
'error_message': self.error_message,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}

View File

@@ -0,0 +1,245 @@
from datetime import datetime, timedelta
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from app import db
class RecurringInvoice(db.Model):
"""Recurring invoice template model for automated billing"""
__tablename__ = 'recurring_invoices'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False) # Template name/description
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True)
# Recurrence settings
frequency = db.Column(db.String(20), nullable=False) # 'daily', 'weekly', 'monthly', 'yearly'
interval = db.Column(db.Integer, nullable=False, default=1) # Every N periods (e.g., every 2 weeks)
next_run_date = db.Column(db.Date, nullable=False) # Next date to generate invoice
end_date = db.Column(db.Date, nullable=True) # Optional end date for recurrence
# Invoice template settings (copied to generated invoices)
client_name = db.Column(db.String(200), nullable=False)
client_email = db.Column(db.String(200), nullable=True)
client_address = db.Column(db.Text, nullable=True)
due_date_days = db.Column(db.Integer, nullable=False, default=30) # Days from issue date to due date
tax_rate = db.Column(db.Numeric(5, 2), nullable=False, default=0)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
notes = db.Column(db.Text, nullable=True)
terms = db.Column(db.Text, nullable=True)
template_id = db.Column(db.Integer, db.ForeignKey('invoice_templates.id'), nullable=True, index=True)
# Auto-send settings
auto_send = db.Column(db.Boolean, nullable=False, default=False) # Automatically send via email when generated
auto_include_time_entries = db.Column(db.Boolean, nullable=False, default=True) # Include unbilled time entries
# Status
is_active = db.Column(db.Boolean, nullable=False, default=True)
# Metadata
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
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)
last_generated_at = db.Column(db.DateTime, nullable=True) # Last time an invoice was generated
# Relationships
project = db.relationship('Project', backref='recurring_invoices')
client = db.relationship('Client', backref='recurring_invoices')
creator = db.relationship('User', backref='created_recurring_invoices')
template = db.relationship('InvoiceTemplate', backref='recurring_invoices')
generated_invoices = db.relationship('Invoice', backref='recurring_invoice_template', lazy='dynamic', foreign_keys='[Invoice.recurring_invoice_id]')
def __init__(self, name, project_id, client_id, frequency, next_run_date, created_by, **kwargs):
self.name = name
self.project_id = project_id
self.client_id = client_id
self.frequency = frequency
self.next_run_date = next_run_date
self.created_by = created_by
# Set optional fields
self.interval = kwargs.get('interval', 1)
self.end_date = kwargs.get('end_date')
self.client_name = kwargs.get('client_name', '')
self.client_email = kwargs.get('client_email')
self.client_address = kwargs.get('client_address')
self.due_date_days = kwargs.get('due_date_days', 30)
self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0)))
self.currency_code = kwargs.get('currency_code', 'EUR')
self.notes = kwargs.get('notes')
self.terms = kwargs.get('terms')
self.template_id = kwargs.get('template_id')
self.auto_send = kwargs.get('auto_send', False)
self.auto_include_time_entries = kwargs.get('auto_include_time_entries', True)
self.is_active = kwargs.get('is_active', True)
def __repr__(self):
return f'<RecurringInvoice {self.name} ({self.frequency})>'
def calculate_next_run_date(self, from_date=None):
"""Calculate the next run date based on frequency and interval"""
if from_date is None:
from_date = datetime.utcnow().date()
if self.frequency == 'daily':
return from_date + timedelta(days=self.interval)
elif self.frequency == 'weekly':
return from_date + timedelta(weeks=self.interval)
elif self.frequency == 'monthly':
return from_date + relativedelta(months=self.interval)
elif self.frequency == 'yearly':
return from_date + relativedelta(years=self.interval)
else:
raise ValueError(f"Invalid frequency: {self.frequency}")
def should_generate_today(self):
"""Check if invoice should be generated today"""
if not self.is_active:
return False
today = datetime.utcnow().date()
# Check if we've reached the end date
if self.end_date and today > self.end_date:
return False
# Check if it's time to generate
return today >= self.next_run_date
def generate_invoice(self):
"""Generate an invoice from this recurring template"""
from app.models import Invoice, InvoiceItem, TimeEntry, Settings
if not self.should_generate_today():
return None
# Get settings for currency
settings = Settings.get_settings()
currency_code = self.currency_code or (settings.currency if settings else 'EUR')
# Calculate dates
issue_date = datetime.utcnow().date()
due_date = issue_date + timedelta(days=self.due_date_days)
# Generate invoice number
invoice_number = Invoice.generate_invoice_number()
# Create invoice
invoice = Invoice(
invoice_number=invoice_number,
project_id=self.project_id,
client_name=self.client_name,
due_date=due_date,
created_by=self.created_by,
client_id=self.client_id,
client_email=self.client_email,
client_address=self.client_address,
tax_rate=self.tax_rate,
notes=self.notes,
terms=self.terms,
currency_code=currency_code,
template_id=self.template_id,
issue_date=issue_date
)
# Link to recurring invoice template
invoice.recurring_invoice_id = self.id
db.session.add(invoice)
# Auto-include time entries if enabled
if self.auto_include_time_entries:
# Get unbilled time entries for this project
time_entries = TimeEntry.query.filter(
TimeEntry.project_id == self.project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True
).order_by(TimeEntry.start_time.desc()).all()
# Filter out entries already billed
unbilled_entries = []
for entry in time_entries:
already_billed = False
for other_invoice in self.project.invoices:
if other_invoice.id != invoice.id:
for item in other_invoice.items:
if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(','):
already_billed = True
break
if already_billed:
break
if not already_billed:
unbilled_entries.append(entry)
# Group and create invoice items
if unbilled_entries:
from app.models.rate_override import RateOverride
grouped_entries = {}
for entry in unbilled_entries:
if entry.task_id:
key = f"task_{entry.task_id}"
description = f"Task: {entry.task.name if entry.task else 'Unknown Task'}"
else:
key = f"project_{entry.project_id}"
description = f"Project: {entry.project.name}"
if key not in grouped_entries:
grouped_entries[key] = {
'description': description,
'entries': [],
'total_hours': Decimal('0'),
}
grouped_entries[key]['entries'].append(entry)
grouped_entries[key]['total_hours'] += entry.duration_hours
# Create invoice items
hourly_rate = RateOverride.resolve_rate(self.project)
for group in grouped_entries.values():
if group['total_hours'] > 0:
item = InvoiceItem(
invoice_id=invoice.id,
description=group['description'],
quantity=group['total_hours'],
unit_price=hourly_rate,
time_entry_ids=','.join(str(e.id) for e in group['entries'])
)
db.session.add(item)
# Calculate totals
invoice.calculate_totals()
# Update recurring invoice
self.last_generated_at = datetime.utcnow()
self.next_run_date = self.calculate_next_run_date(issue_date)
return invoice
def to_dict(self):
"""Convert recurring invoice to dictionary"""
return {
'id': self.id,
'name': self.name,
'project_id': self.project_id,
'client_id': self.client_id,
'frequency': self.frequency,
'interval': self.interval,
'next_run_date': self.next_run_date.isoformat() if self.next_run_date else None,
'end_date': self.end_date.isoformat() if self.end_date else None,
'client_name': self.client_name,
'client_email': self.client_email,
'due_date_days': self.due_date_days,
'tax_rate': float(self.tax_rate),
'currency_code': self.currency_code,
'auto_send': self.auto_send,
'auto_include_time_entries': self.auto_include_time_entries,
'is_active': self.is_active,
'created_by': self.created_by,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'last_generated_at': self.last_generated_at.isoformat() if self.last_generated_at else None
}

View File

@@ -1560,3 +1560,155 @@ def get_email_config():
'password_set': bool(settings.mail_password),
'default_sender': settings.mail_default_sender or ''
}), 200
# ==================== Email Template Management ====================
@admin_bp.route('/admin/email-templates')
@login_required
@admin_or_permission_required('manage_settings')
def list_email_templates():
"""List all email templates"""
from app.models import InvoiceTemplate
templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()
return render_template('admin/email_templates/list.html', templates=templates)
@admin_bp.route('/admin/email-templates/create', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('manage_settings')
def create_email_template():
"""Create a new email template"""
from app.models import InvoiceTemplate
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
html = request.form.get('html', '').strip()
css = request.form.get('css', '').strip()
is_default = request.form.get('is_default') == 'on'
# Validate
if not name:
flash(_('Template name is required'), 'error')
return render_template('admin/email_templates/create.html')
# Check for duplicate name
existing = InvoiceTemplate.query.filter_by(name=name).first()
if existing:
flash(_('A template with this name already exists'), 'error')
return render_template('admin/email_templates/create.html',
name=name, description=description, html=html, css=css)
# If setting as default, unset other defaults
if is_default:
InvoiceTemplate.query.update({InvoiceTemplate.is_default: False})
# Create template
template = InvoiceTemplate(
name=name,
description=description if description else None,
html=html if html else None,
css=css if css else None,
is_default=is_default
)
db.session.add(template)
if not safe_commit('create_email_template', {'name': name}):
flash(_('Could not create email template due to a database error.'), 'error')
return render_template('admin/email_templates/create.html')
flash(_('Email template created successfully'), 'success')
return redirect(url_for('admin.list_email_templates'))
return render_template('admin/email_templates/create.html')
@admin_bp.route('/admin/email-templates/<int:template_id>')
@login_required
@admin_or_permission_required('manage_settings')
def view_email_template(template_id):
"""View email template details"""
from app.models import InvoiceTemplate
template = InvoiceTemplate.query.get_or_404(template_id)
return render_template('admin/email_templates/view.html', template=template)
@admin_bp.route('/admin/email-templates/<int:template_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('manage_settings')
def edit_email_template(template_id):
"""Edit email template"""
from app.models import InvoiceTemplate
template = InvoiceTemplate.query.get_or_404(template_id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
html = request.form.get('html', '').strip()
css = request.form.get('css', '').strip()
is_default = request.form.get('is_default') == 'on'
# Validate
if not name:
flash(_('Template name is required'), 'error')
return render_template('admin/email_templates/edit.html', template=template)
# Check for duplicate name (excluding current template)
existing = InvoiceTemplate.query.filter(
InvoiceTemplate.name == name,
InvoiceTemplate.id != template_id
).first()
if existing:
flash(_('A template with this name already exists'), 'error')
return render_template('admin/email_templates/edit.html', template=template)
# If setting as default, unset other defaults
if is_default:
InvoiceTemplate.query.filter(InvoiceTemplate.id != template_id).update({InvoiceTemplate.is_default: False})
# Update template
template.name = name
template.description = description if description else None
template.html = html if html else None
template.css = css if css else None
template.is_default = is_default
template.updated_at = datetime.utcnow()
if not safe_commit('edit_email_template', {'template_id': template_id}):
flash(_('Could not update email template due to a database error.'), 'error')
return render_template('admin/email_templates/edit.html', template=template)
flash(_('Email template updated successfully'), 'success')
return redirect(url_for('admin.view_email_template', template_id=template_id))
return render_template('admin/email_templates/edit.html', template=template)
@admin_bp.route('/admin/email-templates/<int:template_id>/delete', methods=['POST'])
@login_required
@admin_or_permission_required('manage_settings')
def delete_email_template(template_id):
"""Delete email template"""
from app.models import InvoiceTemplate
template = InvoiceTemplate.query.get_or_404(template_id)
template_name = template.name
# Check if template is in use
if template.invoices.count() > 0 or template.recurring_invoices.count() > 0:
flash(_('Cannot delete template that is in use by invoices or recurring invoices'), 'error')
return redirect(url_for('admin.list_email_templates'))
db.session.delete(template)
if not safe_commit('delete_email_template', {'template_id': template_id}):
flash(_('Could not delete email template due to a database error.'), 'error')
else:
flash(_('Email template "%(name)s" deleted successfully', name=template_name), 'success')
return redirect(url_for('admin.list_email_templates'))

View File

@@ -197,6 +197,7 @@ def create_invoice():
@login_required
def view_invoice(invoice_id):
"""View invoice details"""
from app.models import InvoiceTemplate
invoice = Invoice.query.get_or_404(invoice_id)
# Check access permissions
@@ -210,7 +211,10 @@ def view_invoice(invoice_id):
"invoice_number": invoice.invoice_number
})
return render_template('invoices/view.html', invoice=invoice)
# Get email templates for selection
email_templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()
return render_template('invoices/view.html', invoice=invoice, email_templates=email_templates)
@invoices_bp.route('/invoices/<int:invoice_id>/edit', methods=['GET', 'POST'])
@login_required
@@ -323,8 +327,10 @@ def edit_invoice(invoice_id):
return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id))
# GET request - show edit form
from app.models import InvoiceTemplate
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
return render_template('invoices/edit.html', invoice=invoice, projects=projects)
email_templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()
return render_template('invoices/edit.html', invoice=invoice, projects=projects, email_templates=email_templates)
@invoices_bp.route('/invoices/<int:invoice_id>/status', methods=['POST'])
@login_required
@@ -931,3 +937,55 @@ def export_invoices_excel():
as_attachment=True,
download_name=filename
)
@invoices_bp.route('/invoices/<int:invoice_id>/send-email', methods=['POST'])
@login_required
def send_invoice_email_route(invoice_id):
"""Send invoice via email"""
invoice = Invoice.query.get_or_404(invoice_id)
# Check access permissions
if not current_user.is_admin and invoice.created_by != current_user.id:
return jsonify({'error': 'Permission denied'}), 403
# Get recipient email from request
recipient_email = request.form.get('recipient_email', '').strip() or request.json.get('recipient_email', '').strip() if request.is_json else ''
if not recipient_email:
# Try to use invoice client email
recipient_email = invoice.client_email
if not recipient_email:
return jsonify({'error': 'Recipient email address is required'}), 400
# Get custom message if provided
custom_message = request.form.get('custom_message', '').strip() or (request.json.get('custom_message', '').strip() if request.is_json else '')
# Get email template ID if provided
email_template_id = request.form.get('email_template_id', type=int) or (request.json.get('email_template_id') if request.is_json else None)
try:
from app.utils.email import send_invoice_email
success, invoice_email, message = send_invoice_email(
invoice=invoice,
recipient_email=recipient_email,
sender_user=current_user,
custom_message=custom_message if custom_message else None,
email_template_id=email_template_id
)
if success:
flash(f'Invoice email sent successfully to {recipient_email}', 'success')
return jsonify({
'success': True,
'message': message,
'invoice_email_id': invoice_email.id if invoice_email else None
})
else:
return jsonify({'error': message}), 500
except Exception as e:
logger.error(f"Error sending invoice email: {type(e).__name__}: {str(e)}")
logger.exception("Full error traceback:")
return jsonify({'error': f'Failed to send email: {str(e)}'}), 500

View File

@@ -0,0 +1,264 @@
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
from app.models import RecurringInvoice, Project, Client, Settings, Invoice
from datetime import datetime, timedelta
from decimal import Decimal
import logging
from app.utils.db import safe_commit
recurring_invoices_bp = Blueprint('recurring_invoices', __name__)
logger = logging.getLogger(__name__)
@recurring_invoices_bp.route('/recurring-invoices')
@login_required
def list_recurring_invoices():
"""List all recurring invoices"""
# Get filter parameters
is_active = request.args.get('is_active', '').strip()
# Build query
if current_user.is_admin:
query = RecurringInvoice.query
else:
query = RecurringInvoice.query.filter_by(created_by=current_user.id)
# Apply active filter
if is_active == 'true':
query = query.filter_by(is_active=True)
elif is_active == 'false':
query = query.filter_by(is_active=False)
# Get recurring invoices
recurring_invoices = query.order_by(RecurringInvoice.next_run_date.asc()).all()
return render_template('recurring_invoices/list.html', recurring_invoices=recurring_invoices)
@recurring_invoices_bp.route('/recurring-invoices/create', methods=['GET', 'POST'])
@login_required
def create_recurring_invoice():
"""Create a new recurring invoice"""
if request.method == 'POST':
# Get form data
name = request.form.get('name', '').strip()
project_id = request.form.get('project_id', type=int)
client_id = request.form.get('client_id', type=int)
frequency = request.form.get('frequency', '').strip()
interval = request.form.get('interval', type=int, default=1)
next_run_date_str = request.form.get('next_run_date', '').strip()
end_date_str = request.form.get('end_date', '').strip()
client_name = request.form.get('client_name', '').strip()
client_email = request.form.get('client_email', '').strip()
client_address = request.form.get('client_address', '').strip()
due_date_days = request.form.get('due_date_days', type=int, default=30)
tax_rate = request.form.get('tax_rate', '0').strip()
notes = request.form.get('notes', '').strip()
terms = request.form.get('terms', '').strip()
auto_send = request.form.get('auto_send') == 'on'
auto_include_time_entries = request.form.get('auto_include_time_entries') != 'off'
# Validate required fields
if not name or not project_id or not client_id or not frequency or not next_run_date_str:
flash('Name, project, client, frequency, and next run date are required', 'error')
return render_template('recurring_invoices/create.html')
try:
next_run_date = datetime.strptime(next_run_date_str, '%Y-%m-%d').date()
except ValueError:
flash('Invalid next run date format', 'error')
return render_template('recurring_invoices/create.html')
end_date = None
if end_date_str:
try:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
flash('Invalid end date format', 'error')
return render_template('recurring_invoices/create.html')
try:
tax_rate = Decimal(tax_rate)
except ValueError:
flash('Invalid tax rate format', 'error')
return render_template('recurring_invoices/create.html')
# Get project and client
project = Project.query.get(project_id)
client = Client.query.get(client_id)
if not project or not client:
flash('Selected project or client not found', 'error')
return render_template('recurring_invoices/create.html')
# Get currency from settings
settings = Settings.get_settings()
currency_code = settings.currency if settings else 'EUR'
# Use client info if not provided
if not client_name:
client_name = client.name
if not client_email:
client_email = client.email
# Create recurring invoice
recurring = RecurringInvoice(
name=name,
project_id=project_id,
client_id=client_id,
frequency=frequency,
next_run_date=next_run_date,
created_by=current_user.id,
interval=interval,
end_date=end_date,
client_name=client_name,
client_email=client_email,
client_address=client_address,
due_date_days=due_date_days,
tax_rate=tax_rate,
notes=notes,
terms=terms,
currency_code=currency_code,
auto_send=auto_send,
auto_include_time_entries=auto_include_time_entries
)
db.session.add(recurring)
if not safe_commit('create_recurring_invoice', {'name': name, 'project_id': project_id}):
flash('Could not create recurring invoice due to a database error. Please check server logs.', 'error')
return render_template('recurring_invoices/create.html')
flash(f'Recurring invoice "{name}" created successfully', 'success')
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
# GET request - show form
projects = Project.query.filter_by(status='active', billable=True).order_by(Project.name).all()
clients = Client.query.filter_by(status='active').order_by(Client.name).all()
settings = Settings.get_settings()
# Set default next run date to tomorrow
default_next_run_date = (datetime.utcnow() + timedelta(days=1)).strftime('%Y-%m-%d')
return render_template('recurring_invoices/create.html',
projects=projects,
clients=clients,
settings=settings,
default_next_run_date=default_next_run_date)
@recurring_invoices_bp.route('/recurring-invoices/<int:recurring_id>')
@login_required
def view_recurring_invoice(recurring_id):
"""View recurring invoice details"""
recurring = RecurringInvoice.query.get_or_404(recurring_id)
# Check access permissions
if not current_user.is_admin and recurring.created_by != current_user.id:
flash('You do not have permission to view this recurring invoice', 'error')
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
# Get generated invoices
generated_invoices = recurring.generated_invoices.order_by(Invoice.created_at.desc()).limit(10).all()
return render_template('recurring_invoices/view.html', recurring=recurring, generated_invoices=generated_invoices)
@recurring_invoices_bp.route('/recurring-invoices/<int:recurring_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_recurring_invoice(recurring_id):
"""Edit recurring invoice"""
recurring = RecurringInvoice.query.get_or_404(recurring_id)
# Check access permissions
if not current_user.is_admin and recurring.created_by != current_user.id:
flash('You do not have permission to edit this recurring invoice', 'error')
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
if request.method == 'POST':
# Update recurring invoice
recurring.name = request.form.get('name', '').strip()
recurring.frequency = request.form.get('frequency', '').strip()
recurring.interval = request.form.get('interval', type=int, default=1)
recurring.next_run_date = datetime.strptime(request.form.get('next_run_date'), '%Y-%m-%d').date()
end_date_str = request.form.get('end_date', '').strip()
recurring.end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None
recurring.client_name = request.form.get('client_name', '').strip()
recurring.client_email = request.form.get('client_email', '').strip()
recurring.client_address = request.form.get('client_address', '').strip()
recurring.due_date_days = request.form.get('due_date_days', type=int, default=30)
recurring.tax_rate = Decimal(request.form.get('tax_rate', '0'))
recurring.notes = request.form.get('notes', '').strip()
recurring.terms = request.form.get('terms', '').strip()
recurring.auto_send = request.form.get('auto_send') == 'on'
recurring.auto_include_time_entries = request.form.get('auto_include_time_entries') != 'off'
recurring.is_active = request.form.get('is_active') == 'on'
if not safe_commit('edit_recurring_invoice', {'recurring_id': recurring.id}):
flash('Could not update recurring invoice due to a database error. Please check server logs.', 'error')
return render_template('recurring_invoices/edit.html', recurring=recurring, projects=Project.query.filter_by(status='active').order_by(Project.name).all(), clients=Client.query.filter_by(status='active').order_by(Client.name).all())
flash('Recurring invoice updated successfully', 'success')
return redirect(url_for('recurring_invoices.view_recurring_invoice', recurring_id=recurring.id))
# GET request - show edit form
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.query.filter_by(status='active').order_by(Client.name).all()
return render_template('recurring_invoices/edit.html', recurring=recurring, projects=projects, clients=clients)
@recurring_invoices_bp.route('/recurring-invoices/<int:recurring_id>/delete', methods=['POST'])
@login_required
def delete_recurring_invoice(recurring_id):
"""Delete recurring invoice"""
recurring = RecurringInvoice.query.get_or_404(recurring_id)
# Check access permissions
if not current_user.is_admin and recurring.created_by != current_user.id:
flash('You do not have permission to delete this recurring invoice', 'error')
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
name = recurring.name
db.session.delete(recurring)
if not safe_commit('delete_recurring_invoice', {'recurring_id': recurring.id}):
flash('Could not delete recurring invoice due to a database error. Please check server logs.', 'error')
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
flash(f'Recurring invoice "{name}" deleted successfully', 'success')
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
@recurring_invoices_bp.route('/recurring-invoices/<int:recurring_id>/generate', methods=['POST'])
@login_required
def generate_invoice_now(recurring_id):
"""Manually generate an invoice from a recurring template"""
recurring = RecurringInvoice.query.get_or_404(recurring_id)
# Check access permissions
if not current_user.is_admin and recurring.created_by != current_user.id:
return jsonify({'error': 'Permission denied'}), 403
try:
# Temporarily set next_run_date to today to allow generation
original_next_run_date = recurring.next_run_date
recurring.next_run_date = datetime.utcnow().date()
invoice = recurring.generate_invoice()
if invoice:
db.session.commit()
flash(f'Invoice {invoice.invoice_number} generated successfully', 'success')
return jsonify({
'success': True,
'invoice_id': invoice.id,
'invoice_number': invoice.invoice_number
})
else:
recurring.next_run_date = original_next_run_date
return jsonify({'error': 'Failed to generate invoice'}), 400
except Exception as e:
logger.error(f"Error generating invoice from recurring template: {e}")
return jsonify({'error': str(e)}), 500

View File

@@ -367,12 +367,12 @@ class EnhancedErrorHandler {
if (window.toastManager) {
const toastId = window.toastManager.error(message, 'Error', 0);
// Find toast element by ID or by checking the container
// Find toast element by ID
const toastElement = window.toastManager.container.querySelector(
`[data-toast-id="${toastId}"]`
) || Array.from(window.toastManager.container.children).find(
el => el.getAttribute('data-toast-id') === String(toastId)
) || document.querySelector(`#toast-${toastId}`);
);
if (toastElement) {
const retryContainer = document.createElement('div');

View File

@@ -48,8 +48,8 @@ class ToastNotificationManager {
dismissible: options.dismissible !== false
};
const toast = this.createToast(config);
const toastId = Date.now() + Math.random();
const toast = this.createToast(config, toastId);
this.toasts.set(toastId, {
element: toast,
@@ -80,12 +80,15 @@ class ToastNotificationManager {
return toastId;
}
createToast(config) {
createToast(config, toastId) {
const toast = document.createElement('div');
toast.className = `toast-notification toast-${config.type}`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', config.type === 'error' ? 'assertive' : 'polite');
toast.setAttribute('aria-atomic', 'true');
if (toastId) {
toast.setAttribute('data-toast-id', String(toastId));
}
// Icon
const icon = this.getIcon(config.type);

View File

@@ -43,6 +43,11 @@
<div>Email Configuration</div>
<div class="text-xs mt-1 opacity-90">Test & Configure</div>
</a>
<a href="{{ url_for('admin.list_email_templates') }}" class="bg-purple-600 text-white p-4 rounded-lg text-center hover:bg-purple-700">
<i class="fas fa-file-alt mb-2"></i>
<div>Email Templates</div>
<div class="text-xs mt-1 opacity-90">Invoice Email Templates</div>
</a>
<a href="{{ url_for('admin.settings') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">
<i class="fas fa-cog mb-2"></i>
<div>Settings</div>

View File

@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'Email Templates', 'url': url_for('admin.list_email_templates')},
{'text': 'Create Template'}
] %}
{{ page_header(
icon_class='fas fa-envelope',
title_text='Create Email Template',
subtitle_text='Create a new email template for invoice emails',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="md:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Template Name *</label>
<input type="text" id="name" name="name" required value="{{ name or '' }}" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A unique name to identify this template</p>
</div>
<div class="md:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<input type="text" id="description" name="description" value="{{ description or '' }}" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Optional description of this template</p>
</div>
<div class="md:col-span-2">
<label class="flex items-center">
<input type="checkbox" name="is_default" class="rounded border-gray-300 text-primary focus:ring-primary">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Set as default template</span>
</label>
</div>
<div class="md:col-span-2">
<label for="html" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">HTML Template *</label>
<textarea id="html" name="html" rows="15" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ html or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
HTML content for the email. Use Jinja2 syntax: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
</p>
</div>
<div class="md:col-span-2">
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSS Styles (Optional)</label>
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ css or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in &lt;style&gt; tags.</p>
</div>
</div>
<div class="flex justify-end gap-3">
<a href="{{ url_for('admin.list_email_templates') }}" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">{{ _('Cancel') }}</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">{{ _('Create Template') }}</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'Email Templates', 'url': url_for('admin.list_email_templates')},
{'text': template.name}
] %}
{{ page_header(
icon_class='fas fa-envelope',
title_text='Edit Email Template',
subtitle_text=template.name,
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="md:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Template Name *</label>
<input type="text" id="name" name="name" required value="{{ template.name }}" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div class="md:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<input type="text" id="description" name="description" value="{{ template.description or '' }}" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div class="md:col-span-2">
<label class="flex items-center">
<input type="checkbox" name="is_default" {% if template.is_default %}checked{% endif %} class="rounded border-gray-300 text-primary focus:ring-primary">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Set as default template</span>
</label>
</div>
<div class="md:col-span-2">
<label for="html" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">HTML Template *</label>
<textarea id="html" name="html" rows="15" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ template.html or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
HTML content for the email. Use Jinja2 syntax: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
</p>
</div>
<div class="md:col-span-2">
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSS Styles (Optional)</label>
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ template.css or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in &lt;style&gt; tags.</p>
</div>
</div>
<div class="flex justify-end gap-3">
<a href="{{ url_for('admin.list_email_templates') }}" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">{{ _('Cancel') }}</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">{{ _('Update Template') }}</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'Email Templates'}
] %}
{{ page_header(
icon_class='fas fa-envelope',
title_text='Email Templates',
subtitle_text='Manage email templates for invoice emails',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("admin.create_email_template") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Template</a>'
) }}
{% if templates %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-border-light dark:divide-border-dark">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Description</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-card-light dark:bg-card-dark divide-y divide-border-light dark:divide-border-dark">
{% for template in templates %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-text-light dark:text-text-dark">
<a href="{{ url_for('admin.view_email_template', template_id=template.id) }}" class="text-primary hover:underline">{{ template.name }}</a>
</td>
<td class="px-6 py-4 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ template.description or '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if template.is_default %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">Default</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">Active</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted-light dark:text-text-muted-dark">
{{ template.created_at.strftime('%Y-%m-%d') if template.created_at else '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ url_for('admin.view_email_template', template_id=template.id) }}" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-3">View</a>
<a href="{{ url_for('admin.edit_email_template', template_id=template.id) }}" class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 mr-3">Edit</a>
<button type="button" onclick="showDeleteModal('{{ template.id }}', '{{ template.name }}')" class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow text-center text-text-muted-light dark:text-text-muted-dark">
<p class="text-lg">{{ _('No email templates found.') }}</p>
<p class="mt-2">{{ _('Create your first email template to customize invoice emails.') }}</p>
<a href="{{ url_for('admin.create_email_template') }}" class="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary/90">
<i class="fas fa-plus mr-2"></i>{{ _('Create Email Template') }}
</a>
</div>
{% endif %}
<!-- Delete Modal -->
<div id="deleteTemplateModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="mt-3 text-center">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">{{ _('Delete Email Template') }}</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('Are you sure you want to delete') }} "<strong id="deleteTemplateName"></strong>"? {{ _('This action cannot be undone.') }}</p>
</div>
<div class="items-center px-4 py-3">
<button id="cancelDelete" onclick="hideDeleteModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500 mr-2">{{ _('Cancel') }}</button>
<form id="deleteTemplateForm" method="POST" class="inline-block">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">{{ _('Delete') }}</button>
</form>
</div>
</div>
</div>
</div>
<script>
function showDeleteModal(templateId, name) {
document.getElementById('deleteTemplateName').textContent = name;
document.getElementById('deleteTemplateForm').action = "{{ url_for('admin.delete_email_template', template_id=0) }}".replace('0', templateId);
document.getElementById('deleteTemplateModal').classList.remove('hidden');
}
function hideDeleteModal() {
document.getElementById('deleteTemplateModal').classList.add('hidden');
}
</script>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'Email Templates', 'url': url_for('admin.list_email_templates')},
{'text': template.name}
] %}
{{ page_header(
icon_class='fas fa-envelope',
title_text=template.name,
subtitle_text='Email template details',
breadcrumbs=breadcrumbs,
actions_html='' +
'<a href="' + url_for("admin.edit_email_template", template_id=template.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>'
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Template Information</h2>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ template.name }}</dd>
</div>
{% if template.description %}
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Description</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ template.description }}</dd>
</div>
{% endif %}
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">
{% if template.is_default %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">Default</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">Active</span>
{% endif %}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else '-' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Updated</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ template.updated_at.strftime('%Y-%m-%d %H:%M') if template.updated_at else '-' }}</dd>
</div>
</dl>
</div>
{% if template.html %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">HTML Template</h2>
<pre class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto text-sm"><code>{{ template.html }}</code></pre>
</div>
{% endif %}
{% if template.css %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">CSS Styles</h2>
<pre class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto text-sm"><code>{{ template.css }}</code></pre>
</div>
{% endif %}
{% endblock %}

View File

@@ -162,7 +162,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('weekly_goals.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') or ep.startswith('mileage.') or (ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates')) %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('recurring_invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') or ep.startswith('mileage.') or (ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates')) %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) or ep.startswith('time_entry_templates.') or ep.startswith('audit_logs.') %}
@@ -240,6 +240,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_recurring_invoices = ep.startswith('recurring_invoices.') %}
{% set nav_active_payments = ep.startswith('payments.') %}
{% set nav_active_expenses = ep.startswith('expenses.') %}
{% set nav_active_mileage = ep.startswith('mileage.') %}
@@ -255,6 +256,11 @@
<i class="fas fa-file-invoice w-4 mr-2"></i>{{ _('Invoices') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_recurring_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('recurring_invoices.list_recurring_invoices') }}">
<i class="fas fa-sync-alt w-4 mr-2"></i>{{ _('Recurring 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') }}">
<i class="fas fa-credit-card w-4 mr-2"></i>{{ _('Payments') }}

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #3b82f6;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px;
}
.content {
background-color: #f9fafb;
padding: 20px;
margin-top: 20px;
border-radius: 5px;
}
.invoice-details {
background-color: white;
padding: 15px;
margin: 15px 0;
border-left: 4px solid #3b82f6;
}
.invoice-details table {
width: 100%;
border-collapse: collapse;
}
.invoice-details td {
padding: 8px 0;
}
.invoice-details td:first-child {
font-weight: bold;
width: 40%;
}
.custom-message {
background-color: #fef3c7;
padding: 15px;
margin: 15px 0;
border-left: 4px solid #f59e0b;
border-radius: 5px;
}
.footer {
text-align: center;
color: #6b7280;
font-size: 12px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
</style>
</head>
<body>
<div class="header">
<h1>Invoice {{ invoice.invoice_number }}</h1>
</div>
<div class="content">
<p>Hello,</p>
<p>Please find attached invoice <strong>{{ invoice.invoice_number }}</strong> for your records.</p>
<div class="invoice-details">
<table>
<tr>
<td>Invoice Number:</td>
<td>{{ invoice.invoice_number }}</td>
</tr>
<tr>
<td>Issue Date:</td>
<td>{{ invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else 'N/A' }}</td>
</tr>
<tr>
<td>Due Date:</td>
<td>{{ invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A' }}</td>
</tr>
<tr>
<td>Amount:</td>
<td><strong>{{ invoice.currency_code }} {{ invoice.total_amount }}</strong></td>
</tr>
</table>
</div>
{% if custom_message %}
<div class="custom-message">
<p><strong>Message:</strong></p>
<p>{{ custom_message }}</p>
</div>
{% endif %}
<p>Please remit payment by the due date.</p>
<p>Thank you for your business!</p>
</div>
<div class="footer">
<p><strong>{{ company_name }}</strong></p>
<p>TimeTracker - Time Tracking & Project Management</p>
<p>This invoice was sent automatically. Please contact us if you have any questions.</p>
</div>
</body>
</html>

View File

@@ -10,6 +10,9 @@
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition">
<i class="fas fa-arrow-left mr-2"></i>{{ _('Back') }}
</a>
<button type="button" onclick="showSendEmailModal()" class="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600">
<i class="fas fa-envelope mr-1"></i>{{ _('Send Email') }}
</button>
</div>
</div>
@@ -522,4 +525,122 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
</script>
<!-- Send Email Modal -->
<div id="sendEmailModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Send Invoice via Email</h3>
<form id="sendEmailForm" onsubmit="sendInvoiceEmail(event)">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="recipient_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Recipient Email *</label>
<input type="email" id="recipient_email" name="recipient_email" value="{{ invoice.client_email }}" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
</div>
<div class="mb-4">
<label for="email_template_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Template (Optional)</label>
<select id="email_template_id" name="email_template_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<option value="">Default Template</option>
{% for template in email_templates %}
<option value="{{ template.id }}">{{ template.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label for="custom_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Custom Message (Optional)</label>
<textarea id="custom_message" name="custom_message" rows="4" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"></textarea>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="hideSendEmailModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Send Email</button>
</div>
</form>
</div>
</div>
</div>
<script>
function showSendEmailModal() {
const modal = document.getElementById('sendEmailModal');
if (modal) modal.classList.remove('hidden');
}
function hideSendEmailModal() {
const modal = document.getElementById('sendEmailModal');
if (modal) modal.classList.add('hidden');
}
function sendInvoiceEmail(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
fetch("{{ url_for('invoices.send_invoice_email_route', invoice_id=invoice.id) }}", {
method: 'POST',
body: formData
})
.then(async response => {
// Check content type to determine how to parse
const contentType = response.headers.get('content-type') || '';
const isJson = contentType.includes('application/json');
// Always try JSON first if content-type suggests it, otherwise read as text
if (isJson) {
const data = await response.json();
// Handle both success and error responses
if (!response.ok) {
return { success: false, error: data.error || data.message || `HTTP ${response.status}: ${response.statusText}` };
}
return data;
} else {
// Not JSON, read as text (only once)
const text = await response.text();
if (!response.ok) {
throw new Error(text || `HTTP ${response.status}: ${response.statusText}`);
}
// If successful but not JSON, return error format
return { success: false, error: text || 'Unexpected response format' };
}
})
.then(data => {
if (data.success) {
if (window.toastManager) {
window.toastManager.success('Invoice email sent successfully!');
} else {
alert('Invoice email sent successfully!');
}
hideSendEmailModal();
// Optionally reload the page to show updated status
setTimeout(() => window.location.reload(), 1000);
} else {
const errorMsg = data.error || 'Failed to send email';
console.error('Email send error:', errorMsg);
if (window.toastManager) {
window.toastManager.error('Error: ' + errorMsg);
} else {
alert('Error: ' + errorMsg);
}
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
})
.catch(error => {
console.error('Email send error:', error);
const errorMsg = error.message || 'Failed to send email. Please check server logs for details.';
if (window.toastManager) {
window.toastManager.error(errorMsg);
} else {
alert(errorMsg);
}
submitBtn.disabled = false;
submitBtn.textContent = originalText;
});
}
</script>
{% endblock %}

View File

@@ -14,6 +14,7 @@
breadcrumbs=breadcrumbs,
actions_html=''
+ '<div class="flex gap-2">'
+ '<a href="' + url_for("recurring_invoices.list_recurring_invoices") + '" class="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 inline-flex items-center"><i class="fas fa-sync-alt mr-2"></i>Recurring Invoices</a>'
+ '<a href="' + url_for("invoices.export_invoices_excel") + '" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-flex items-center"><i class="fas fa-file-excel mr-2"></i>Export to Excel</a>'
+ '<a href="' + url_for("invoices.create_invoice") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Invoice</a>'
+ '</div>'

View File

@@ -16,6 +16,9 @@
</select>
<a id="export-pdf-link" href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="bg-secondary text-white px-4 py-2 rounded-lg hover:bg-secondary-dark">Export PDF</a>
</div>
<button type="button" onclick="showSendEmailModal()" class="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600">
<i class="fas fa-envelope mr-1"></i>Send Email
</button>
<a href="{{ url_for('payments.create_payment', invoice_id=invoice.id) }}" class="bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-600">Record Payment</a>
<button type="button" onclick="showDeleteModal('{{ invoice.id }}', '{{ invoice.invoice_number }}')" class="bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600">
<i class="fas fa-trash mr-1"></i>Delete
@@ -276,7 +279,123 @@
</div>
</div>
<!-- Send Email Modal -->
<div id="sendEmailModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Send Invoice via Email</h3>
<form id="sendEmailForm" onsubmit="sendInvoiceEmail(event)">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="recipient_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Recipient Email *</label>
<input type="email" id="recipient_email" name="recipient_email" value="{{ invoice.client_email }}" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
</div>
<div class="mb-4">
<label for="email_template_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Template (Optional)</label>
<select id="email_template_id" name="email_template_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<option value="">Default Template</option>
{% for template in email_templates %}
<option value="{{ template.id }}">{{ template.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label for="custom_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Custom Message (Optional)</label>
<textarea id="custom_message" name="custom_message" rows="4" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"></textarea>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="hideSendEmailModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Send Email</button>
</div>
</form>
</div>
</div>
</div>
<script>
function showSendEmailModal() {
const modal = document.getElementById('sendEmailModal');
if (modal) modal.classList.remove('hidden');
}
function hideSendEmailModal() {
const modal = document.getElementById('sendEmailModal');
if (modal) modal.classList.add('hidden');
}
function sendInvoiceEmail(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
fetch("{{ url_for('invoices.send_invoice_email_route', invoice_id=invoice.id) }}", {
method: 'POST',
body: formData
})
.then(async response => {
// Check content type to determine how to parse
const contentType = response.headers.get('content-type') || '';
const isJson = contentType.includes('application/json');
// Always try JSON first if content-type suggests it, otherwise read as text
if (isJson) {
const data = await response.json();
// Handle both success and error responses
if (!response.ok) {
return { success: false, error: data.error || data.message || `HTTP ${response.status}: ${response.statusText}` };
}
return data;
} else {
// Not JSON, read as text (only once)
const text = await response.text();
if (!response.ok) {
throw new Error(text || `HTTP ${response.status}: ${response.statusText}`);
}
// If successful but not JSON, return error format
return { success: false, error: text || 'Unexpected response format' };
}
})
.then(data => {
if (data.success) {
if (window.toastManager) {
window.toastManager.success('Invoice email sent successfully!');
} else {
alert('Invoice email sent successfully!');
}
hideSendEmailModal();
// Optionally reload the page to show updated status
setTimeout(() => window.location.reload(), 1000);
} else {
const errorMsg = data.error || 'Failed to send email';
console.error('Email send error:', errorMsg);
if (window.toastManager) {
window.toastManager.error('Error: ' + errorMsg);
} else {
alert('Error: ' + errorMsg);
}
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
})
.catch(error => {
console.error('Email send error:', error);
const errorMsg = error.message || 'Failed to send email. Please check server logs for details.';
if (window.toastManager) {
window.toastManager.error(errorMsg);
} else {
alert(errorMsg);
}
submitBtn.disabled = false;
submitBtn.textContent = originalText;
});
}
function showDeleteModal(invoiceId, invoiceNumber) {
const numberEl = document.getElementById('deleteInvoiceNumber');
const formEl = document.getElementById('deleteInvoiceForm');

View File

@@ -0,0 +1,167 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Recurring Invoices', 'url': url_for('recurring_invoices.list_recurring_invoices')},
{'text': 'Create'}
] %}
{{ page_header(
icon_class='fas fa-sync-alt',
title_text='Create Recurring Invoice',
subtitle_text='Set up automated invoice generation',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Template Name *</label>
<input type="text" id="name" name="name" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project *</label>
<select id="project_id" name="project_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<option value="">Select Project</option>
{% for project in projects %}
<option value="{{ project.id }}" data-client-id="{{ project.client_id if project.client_id else '' }}" data-client-name="{{ project.client_obj.name if project.client_obj else '' }}" data-client-email="{{ project.client_obj.email if project.client_obj else '' }}" data-client-address="{{ project.client_obj.address if project.client_obj else '' }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client *</label>
<select id="client_id" name="client_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<option value="">Select Client</option>
{% for client in clients %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="frequency" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency *</label>
<select id="frequency" name="frequency" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly" selected>Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div>
<label for="interval" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Interval</label>
<input type="number" id="interval" name="interval" value="1" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<p class="text-xs text-gray-500 mt-1">Every N periods (e.g., every 2 weeks)</p>
</div>
<div>
<label for="next_run_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Run Date *</label>
<input type="date" id="next_run_date" name="next_run_date" value="{{ default_next_run_date }}" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">End Date (Optional)</label>
<input type="date" id="end_date" name="end_date" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="due_date_days" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Due Date (Days from Issue)</label>
<input type="number" id="due_date_days" name="due_date_days" value="30" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tax Rate (%)</label>
<input type="number" id="tax_rate" name="tax_rate" value="0" step="0.01" min="0" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
</div>
<div>
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Name</label>
<input type="text" id="client_name" name="client_name" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Email</label>
<input type="email" id="client_email" name="client_email" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Address</label>
<textarea id="client_address" name="client_address" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"></textarea>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="notes" name="notes" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"></textarea>
</div>
<div>
<label for="terms" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Terms</label>
<textarea id="terms" name="terms" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"></textarea>
</div>
<div class="flex items-center space-x-4">
<label class="flex items-center">
<input type="checkbox" name="auto_send" class="mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">Automatically send invoices via email when generated</span>
</label>
</div>
<div class="flex items-center space-x-4">
<label class="flex items-center">
<input type="checkbox" name="auto_include_time_entries" checked class="mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">Automatically include unbilled time entries</span>
</label>
</div>
<div class="flex justify-end space-x-4">
<a href="{{ url_for('recurring_invoices.list_recurring_invoices') }}" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800">Cancel</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">Create Recurring Invoice</button>
</div>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var projectSelect = document.getElementById('project_id');
var clientSelect = document.getElementById('client_id');
var clientName = document.getElementById('client_name');
var clientEmail = document.getElementById('client_email');
var clientAddress = document.getElementById('client_address');
if (projectSelect && clientSelect) {
projectSelect.addEventListener('change', function() {
var opt = projectSelect.options[projectSelect.selectedIndex];
if (!opt || !opt.value) return;
var clientId = opt.getAttribute('data-client-id');
var name = opt.getAttribute('data-client-name') || '';
var email = opt.getAttribute('data-client-email') || '';
var address = opt.getAttribute('data-client-address') || '';
// Set client dropdown
if (clientId) {
clientSelect.value = clientId;
}
// Set client details fields
if (name && clientName) {
clientName.value = name;
}
if (email && clientEmail) {
clientEmail.value = email;
}
if (address && clientAddress) {
clientAddress.value = address;
}
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Recurring Invoices', 'url': url_for('recurring_invoices.list_recurring_invoices')},
{'text': recurring.name, 'url': url_for('recurring_invoices.view_recurring_invoice', recurring_id=recurring.id)},
{'text': 'Edit'}
] %}
{{ page_header(
icon_class='fas fa-sync-alt',
title_text='Edit Recurring Invoice',
subtitle_text=recurring.name,
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Template Name *</label>
<input type="text" id="name" name="name" value="{{ recurring.name }}" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="frequency" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency *</label>
<select id="frequency" name="frequency" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<option value="daily" {% if recurring.frequency == 'daily' %}selected{% endif %}>Daily</option>
<option value="weekly" {% if recurring.frequency == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="monthly" {% if recurring.frequency == 'monthly' %}selected{% endif %}>Monthly</option>
<option value="yearly" {% if recurring.frequency == 'yearly' %}selected{% endif %}>Yearly</option>
</select>
</div>
<div>
<label for="interval" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Interval</label>
<input type="number" id="interval" name="interval" value="{{ recurring.interval }}" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="next_run_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Run Date *</label>
<input type="date" id="next_run_date" name="next_run_date" value="{{ recurring.next_run_date.strftime('%Y-%m-%d') if recurring.next_run_date else '' }}" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">End Date (Optional)</label>
<input type="date" id="end_date" name="end_date" value="{{ recurring.end_date.strftime('%Y-%m-%d') if recurring.end_date else '' }}" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="due_date_days" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Due Date (Days from Issue)</label>
<input type="number" id="due_date_days" name="due_date_days" value="{{ recurring.due_date_days }}" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tax Rate (%)</label>
<input type="number" id="tax_rate" name="tax_rate" value="{{ recurring.tax_rate }}" step="0.01" min="0" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
</div>
<div>
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Name</label>
<input type="text" id="client_name" name="client_name" value="{{ recurring.client_name }}" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Email</label>
<input type="email" id="client_email" name="client_email" value="{{ recurring.client_email or '' }}" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
</div>
<div>
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Address</label>
<textarea id="client_address" name="client_address" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">{{ recurring.client_address or '' }}</textarea>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="notes" name="notes" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">{{ recurring.notes or '' }}</textarea>
</div>
<div>
<label for="terms" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Terms</label>
<textarea id="terms" name="terms" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">{{ recurring.terms or '' }}</textarea>
</div>
<div class="flex items-center space-x-4">
<label class="flex items-center">
<input type="checkbox" name="auto_send" {% if recurring.auto_send %}checked{% endif %} class="mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">Automatically send invoices via email when generated</span>
</label>
</div>
<div class="flex items-center space-x-4">
<label class="flex items-center">
<input type="checkbox" name="auto_include_time_entries" {% if recurring.auto_include_time_entries %}checked{% endif %} class="mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">Automatically include unbilled time entries</span>
</label>
</div>
<div class="flex items-center space-x-4">
<label class="flex items-center">
<input type="checkbox" name="is_active" {% if recurring.is_active %}checked{% endif %} class="mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
</div>
<div class="flex justify-end space-x-4">
<a href="{{ url_for('recurring_invoices.view_recurring_invoice', recurring_id=recurring.id) }}" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800">Cancel</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">Update Recurring Invoice</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, button %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Recurring Invoices'}
] %}
{{ page_header(
icon_class='fas fa-sync-alt',
title_text='Recurring Invoices',
subtitle_text='Automate invoice generation for subscription-based billing',
breadcrumbs=breadcrumbs,
actions_html=''
+ '<a href="' + url_for("recurring_invoices.create_recurring_invoice") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Recurring Invoice</a>'
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<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-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Project</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Client</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Frequency</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Next Run</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-900 divide-y divide-gray-200 dark:divide-gray-700">
{% for recurring in recurring_invoices %}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ recurring.name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ recurring.project.name if recurring.project else 'N/A' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ recurring.client_name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500 dark:text-gray-400">
Every {{ recurring.interval }} {{ recurring.frequency }}{{ 's' if recurring.interval > 1 else '' }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ recurring.next_run_date.strftime('%Y-%m-%d') if recurring.next_run_date else 'N/A' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if recurring.is_active %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Active</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">Inactive</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ url_for('recurring_invoices.view_recurring_invoice', recurring_id=recurring.id) }}" class="text-primary hover:text-primary/80 mr-3">View</a>
<a href="{{ url_for('recurring_invoices.edit_recurring_invoice', recurring_id=recurring.id) }}" class="text-blue-600 hover:text-blue-900 mr-3">Edit</a>
<button onclick="generateInvoice({{ recurring.id }})" class="text-green-600 hover:text-green-900 mr-3">Generate Now</button>
<form method="POST" action="{{ url_for('recurring_invoices.delete_recurring_invoice', recurring_id=recurring.id) }}" class="inline" onsubmit="return confirm('Are you sure you want to delete this recurring invoice?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-600 hover:text-red-900">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">No recurring invoices found. <a href="{{ url_for('recurring_invoices.create_recurring_invoice') }}" class="text-primary">Create one</a> to get started.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
function generateInvoice(recurringId) {
if (!confirm('Generate an invoice from this recurring template now?')) {
return;
}
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch(`/recurring-invoices/${recurringId}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
csrf_token: csrfToken
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Invoice ${data.invoice_number} generated successfully!`);
window.location.href = `/invoices/${data.invoice_id}/edit`;
} else {
alert('Error: ' + (data.error || 'Failed to generate invoice'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to generate invoice');
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Recurring Invoices', 'url': url_for('recurring_invoices.list_recurring_invoices')},
{'text': recurring.name}
] %}
{{ page_header(
icon_class='fas fa-sync-alt',
title_text=recurring.name,
subtitle_text='Recurring Invoice Template',
breadcrumbs=breadcrumbs,
actions_html=''
+ '<a href="' + url_for("recurring_invoices.edit_recurring_invoice", recurring_id=recurring.id) + '" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 mr-2">Edit</a>'
+ '<button onclick="generateInvoice(' + recurring.id|string + ')" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700">Generate Now</button>'
) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Template Details</h3>
<dl class="space-y-2">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.project.name if recurring.project else 'N/A' }}</dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.client_name }}</dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Frequency</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">Every {{ recurring.interval }} {{ recurring.frequency }}{{ 's' if recurring.interval > 1 else '' }}</dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Next Run Date</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.next_run_date.strftime('%Y-%m-%d') if recurring.next_run_date else 'N/A' }}</dd>
{% if recurring.end_date %}
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">End Date</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.end_date.strftime('%Y-%m-%d') }}</dd>
{% endif %}
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd class="text-sm">
{% if recurring.is_active %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Active</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">Inactive</span>
{% endif %}
</dd>
</dl>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Invoice Settings</h3>
<dl class="space-y-2">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Due Date (Days)</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.due_date_days }} days from issue date</dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Tax Rate</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.tax_rate }}%</dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Currency</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.currency_code }}</dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Auto Send</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">
{% if recurring.auto_send %}
<span class="text-green-600">Yes</span>
{% else %}
<span class="text-gray-500">No</span>
{% endif %}
</dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Auto Include Time Entries</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">
{% if recurring.auto_include_time_entries %}
<span class="text-green-600">Yes</span>
{% else %}
<span class="text-gray-500">No</span>
{% endif %}
</dd>
{% if recurring.last_generated_at %}
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Generated</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.last_generated_at.strftime('%Y-%m-%d %H:%M') }}</dd>
{% endif %}
</dl>
</div>
</div>
{% if generated_invoices %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Recently Generated Invoices</h3>
<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-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Invoice Number</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Issue Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% for invoice in generated_invoices %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{{ invoice.invoice_number }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else 'N/A' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ invoice.currency_code }} {{ invoice.total_amount }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ invoice.status }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="text-primary hover:text-primary/80">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<script>
function generateInvoice(recurringId) {
if (!confirm('Generate an invoice from this recurring template now?')) {
return;
}
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch(`/recurring-invoices/${recurringId}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
csrf_token: csrfToken
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Invoice ${data.invoice_number} generated successfully!`);
window.location.href = `/invoices/${data.invoice_id}/edit`;
} else {
alert('Error: ' + (data.error || 'Failed to generate invoice'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to generate invoice');
});
}
</script>
{% endblock %}

View File

@@ -2,9 +2,11 @@
import os
from flask import current_app, render_template, url_for
from jinja2 import Template as JinjaTemplate
from flask_mail import Mail, Message
from threading import Thread
from datetime import datetime, timedelta
from app import db
mail = Mail()
@@ -463,3 +465,228 @@ TimeTracker - Time Tracking & Project Management
current_app.logger.exception("[EMAIL TEST] Full exception trace:")
return False, f'Failed to send test email: {str(e)}'
def send_invoice_email(invoice, recipient_email, sender_user=None, custom_message=None, email_template_id=None):
"""Send an invoice via email with PDF attachment
Args:
invoice: Invoice object
recipient_email: Email address to send to
sender_user: User object who is sending (for tracking)
custom_message: Optional custom message to include in email
email_template_id: Optional email template ID to use
Returns:
tuple: (success: bool, invoice_email: InvoiceEmail or None, message: str)
"""
try:
from app.models import InvoiceEmail, Settings
current_app.logger.info(f"[INVOICE EMAIL] Sending invoice {invoice.invoice_number} to {recipient_email}")
# Generate PDF
pdf_bytes = None
try:
from app.utils.pdf_generator import InvoicePDFGenerator
settings = Settings.get_settings()
pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size='A4')
pdf_bytes = pdf_generator.generate_pdf()
if not pdf_bytes:
raise ValueError("PDF generator returned None")
current_app.logger.info(f"[INVOICE EMAIL] PDF generated successfully - size: {len(pdf_bytes)} bytes")
except Exception as pdf_error:
current_app.logger.warning(f"[INVOICE EMAIL] PDF generation failed, trying fallback: {pdf_error}")
current_app.logger.exception("[INVOICE EMAIL] PDF generation error details:")
try:
from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback
settings = Settings.get_settings()
pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings)
pdf_bytes = pdf_generator.generate_pdf()
if not pdf_bytes:
raise ValueError("PDF fallback generator returned None")
current_app.logger.info(f"[INVOICE EMAIL] PDF generated via fallback - size: {len(pdf_bytes)} bytes")
except Exception as fallback_error:
current_app.logger.error(f"[INVOICE EMAIL] Both PDF generators failed: {fallback_error}")
current_app.logger.exception("[INVOICE EMAIL] Fallback PDF generation error details:")
return False, None, f'PDF generation failed: {str(fallback_error)}'
if not pdf_bytes:
current_app.logger.error("[INVOICE EMAIL] PDF bytes is None after generation")
return False, None, 'PDF generation returned empty result'
# Get settings for email subject/body
settings = Settings.get_settings()
company_name = settings.company_name if settings else 'Your Company'
# Create email subject
subject = f"Invoice {invoice.invoice_number} from {company_name}"
# Get email template if specified
html_body = None
text_body = None
if email_template_id:
try:
from app.models import InvoiceTemplate
email_template = InvoiceTemplate.query.get(email_template_id)
if email_template and email_template.html:
# Use custom template
# Ensure the HTML is properly formatted for email
template_html = email_template.html.strip()
# If CSS is provided separately, wrap it in <style> tags
if email_template.css and email_template.css.strip():
css_content = email_template.css.strip()
# Check if CSS is already wrapped in <style> tags
if not css_content.startswith('<style'):
css_content = f'<style>\n{css_content}\n</style>'
# Insert CSS into HTML if not already present
if '<style>' not in template_html and '</style>' not in template_html:
# Try to insert before </head> or at the beginning if no head tag
if '</head>' in template_html:
template_html = template_html.replace('</head>', f'{css_content}\n</head>')
elif '<body>' in template_html:
template_html = template_html.replace('<body>', f'{css_content}\n<body>')
else:
template_html = f'{css_content}\n{template_html}'
# Ensure HTML has proper structure
if not template_html.strip().startswith('<!DOCTYPE') and not template_html.strip().startswith('<html'):
# Wrap in minimal HTML structure if needed
if '<html' not in template_html:
template_html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
{template_html}
</body>
</html>'''
template = JinjaTemplate(template_html)
html_body = template.render(
invoice=invoice,
company_name=company_name,
custom_message=custom_message
)
# Generate text version from HTML if needed
text_body = f"Invoice {invoice.invoice_number} - Please see attached PDF for details."
except Exception as template_error:
current_app.logger.warning(f"[INVOICE EMAIL] Custom template failed: {template_error}")
current_app.logger.exception("[INVOICE EMAIL] Template error details:")
# Fallback to default template
if not html_body:
# Create email body
text_body = f"""
Hello,
Please find attached invoice {invoice.invoice_number} for your records.
Invoice Details:
- Invoice Number: {invoice.invoice_number}
- Issue Date: {invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else 'N/A'}
- Due Date: {invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A'}
- Amount: {invoice.currency_code} {invoice.total_amount}
"""
if custom_message:
text_body += f"\n{custom_message}\n\n"
text_body += f"""
Please remit payment by the due date.
Thank you for your business!
---
{company_name}
"""
# Render HTML template
try:
html_body = render_template(
'email/invoice.html',
invoice=invoice,
company_name=company_name,
custom_message=custom_message
)
except Exception as template_error:
current_app.logger.warning(f"[INVOICE EMAIL] HTML template not available: {template_error}")
html_body = None
# Get sender user ID
sender_id = sender_user.id if sender_user else None
if not sender_id:
# Try to get from invoice creator
sender_id = invoice.created_by
# Send email synchronously to catch errors
attachments = [
(f'invoice_{invoice.invoice_number}.pdf', 'application/pdf', pdf_bytes)
]
# Create message
msg = Message(
subject=subject,
recipients=[recipient_email],
body=text_body,
html=html_body,
sender=current_app.config['MAIL_DEFAULT_SENDER']
)
# Add attachments
for filename, content_type, data in attachments:
msg.attach(filename, content_type, data)
# Send synchronously to catch errors
try:
current_app.logger.info(f"[INVOICE EMAIL] Attempting to send email to {recipient_email}")
current_app.logger.debug(f"[INVOICE EMAIL] Email config - Server: {current_app.config.get('MAIL_SERVER')}, Port: {current_app.config.get('MAIL_PORT')}")
mail.send(msg)
current_app.logger.info(f"[INVOICE EMAIL] ✓ Email sent successfully to {recipient_email}")
except Exception as send_error:
current_app.logger.error(f"[INVOICE EMAIL] ✗ Failed to send email: {type(send_error).__name__}: {str(send_error)}")
current_app.logger.exception("[INVOICE EMAIL] Email send error details:")
raise send_error
# Create email tracking record
invoice_email = InvoiceEmail(
invoice_id=invoice.id,
recipient_email=recipient_email,
subject=subject,
sent_by=sender_id
)
db.session.add(invoice_email)
# Update invoice status to 'sent' if it's still 'draft'
if invoice.status == 'draft':
invoice.status = 'sent'
db.session.commit()
return True, invoice_email, f'Invoice email sent successfully to {recipient_email}'
except Exception as e:
current_app.logger.error(f"[INVOICE EMAIL] ✗ Failed to send invoice email: {type(e).__name__}: {str(e)}")
current_app.logger.exception("[INVOICE EMAIL] Full exception trace:")
# Try to create failed tracking record
try:
from app.models import InvoiceEmail
sender_id = sender_user.id if sender_user else invoice.created_by
invoice_email = InvoiceEmail(
invoice_id=invoice.id,
recipient_email=recipient_email,
subject=f"Invoice {invoice.invoice_number}",
sent_by=sender_id
)
invoice_email.mark_failed(str(e))
db.session.add(invoice_email)
db.session.commit()
except Exception:
db.session.rollback()
return False, None, f'Failed to send invoice email: {str(e)}'

View File

@@ -4,7 +4,7 @@ import logging
from datetime import datetime, timedelta
from flask import current_app
from app import db
from app.models import Invoice, User, TimeEntry, Project, BudgetAlert
from app.models import Invoice, User, TimeEntry, Project, BudgetAlert, RecurringInvoice
from app.utils.email import send_overdue_invoice_notification, send_weekly_summary
from app.utils.budget_forecasting import check_budget_alerts
@@ -189,6 +189,66 @@ def check_project_budget_alerts():
return 0
def generate_recurring_invoices():
"""Generate invoices from active recurring invoice templates
This task should be run daily to check for recurring invoices that need to be generated.
"""
try:
logger.info("Generating recurring invoices...")
# Get all active recurring invoices that should generate today
today = datetime.utcnow().date()
recurring_invoices = RecurringInvoice.query.filter(
RecurringInvoice.is_active == True,
RecurringInvoice.next_run_date <= today
).all()
logger.info(f"Found {len(recurring_invoices)} recurring invoices to process")
invoices_generated = 0
emails_sent = 0
for recurring in recurring_invoices:
try:
# Check if we've reached the end date
if recurring.end_date and today > recurring.end_date:
logger.info(f"Recurring invoice {recurring.id} has reached end date, deactivating")
recurring.is_active = False
db.session.commit()
continue
# Generate invoice
invoice = recurring.generate_invoice()
if invoice:
db.session.commit()
invoices_generated += 1
logger.info(f"Generated invoice {invoice.invoice_number} from recurring template {recurring.name}")
# Auto-send if enabled
if recurring.auto_send and invoice.client_email:
try:
from app.utils.email import send_invoice_email
send_invoice_email(invoice, invoice.client_email, sender_user=recurring.creator)
emails_sent += 1
logger.info(f"Auto-sent invoice {invoice.invoice_number} to {invoice.client_email}")
except Exception as e:
logger.error(f"Failed to auto-send invoice {invoice.invoice_number}: {e}")
else:
logger.warning(f"Failed to generate invoice from recurring template {recurring.id}")
except Exception as e:
logger.error(f"Error processing recurring invoice {recurring.id}: {e}")
db.session.rollback()
logger.info(f"Generated {invoices_generated} invoices, sent {emails_sent} emails")
return invoices_generated
except Exception as e:
logger.error(f"Error generating recurring invoices: {e}")
return 0
def register_scheduled_tasks(scheduler):
"""Register all scheduled tasks with APScheduler
@@ -233,6 +293,18 @@ def register_scheduled_tasks(scheduler):
)
logger.info("Registered budget alerts check task")
# Generate recurring invoices daily at 8 AM
scheduler.add_job(
func=generate_recurring_invoices,
trigger='cron',
hour=8,
minute=0,
id='generate_recurring_invoices',
name='Generate recurring invoices',
replace_existing=True
)
logger.info("Registered recurring invoices generation task")
except Exception as e:
logger.error(f"Error registering scheduled tasks: {e}")

View File

@@ -0,0 +1,116 @@
"""Add recurring invoices and email tracking
Revision ID: 045
Revises: 044
Create Date: 2025-01-22
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '045'
down_revision = '044'
branch_labels = None
depends_on = None
def upgrade():
"""Create recurring_invoices and invoice_emails tables, add recurring_invoice_id to invoices"""
# Create recurring_invoices table
op.create_table('recurring_invoices',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=False),
sa.Column('client_id', sa.Integer(), nullable=False),
sa.Column('frequency', sa.String(length=20), nullable=False),
sa.Column('interval', sa.Integer(), nullable=False, server_default='1'),
sa.Column('next_run_date', sa.Date(), nullable=False),
sa.Column('end_date', sa.Date(), nullable=True),
sa.Column('client_name', sa.String(length=200), nullable=False),
sa.Column('client_email', sa.String(length=200), nullable=True),
sa.Column('client_address', sa.Text(), nullable=True),
sa.Column('due_date_days', sa.Integer(), nullable=False, server_default='30'),
sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=False, server_default='0'),
sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('terms', sa.Text(), nullable=True),
sa.Column('template_id', sa.Integer(), nullable=True),
sa.Column('auto_send', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('auto_include_time_entries', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('last_generated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['template_id'], ['invoice_templates.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for recurring_invoices
op.create_index('ix_recurring_invoices_project_id', 'recurring_invoices', ['project_id'])
op.create_index('ix_recurring_invoices_client_id', 'recurring_invoices', ['client_id'])
op.create_index('ix_recurring_invoices_next_run_date', 'recurring_invoices', ['next_run_date'])
op.create_index('ix_recurring_invoices_is_active', 'recurring_invoices', ['is_active'])
# Create invoice_emails table
op.create_table('invoice_emails',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('invoice_id', sa.Integer(), nullable=False),
sa.Column('recipient_email', sa.String(length=200), nullable=False),
sa.Column('subject', sa.String(length=500), nullable=False),
sa.Column('sent_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('sent_by', sa.Integer(), nullable=False),
sa.Column('opened_at', sa.DateTime(), nullable=True),
sa.Column('opened_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('last_opened_at', sa.DateTime(), nullable=True),
sa.Column('paid_at', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False, server_default='sent'),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['sent_by'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for invoice_emails
op.create_index('ix_invoice_emails_invoice_id', 'invoice_emails', ['invoice_id'])
op.create_index('ix_invoice_emails_recipient_email', 'invoice_emails', ['recipient_email'])
op.create_index('ix_invoice_emails_status', 'invoice_emails', ['status'])
op.create_index('ix_invoice_emails_sent_at', 'invoice_emails', ['sent_at'])
# Add recurring_invoice_id to invoices table
op.add_column('invoices', sa.Column('recurring_invoice_id', sa.Integer(), nullable=True))
op.create_index('ix_invoices_recurring_invoice_id', 'invoices', ['recurring_invoice_id'])
op.create_foreign_key('fk_invoices_recurring_invoice_id', 'invoices', 'recurring_invoices', ['recurring_invoice_id'], ['id'], ondelete='SET NULL')
def downgrade():
"""Remove recurring invoices and email tracking tables"""
# Remove recurring_invoice_id from invoices
op.drop_constraint('fk_invoices_recurring_invoice_id', 'invoices', type_='foreignkey')
op.drop_index('ix_invoices_recurring_invoice_id', table_name='invoices')
op.drop_column('invoices', 'recurring_invoice_id')
# Drop invoice_emails table
op.drop_index('ix_invoice_emails_sent_at', table_name='invoice_emails')
op.drop_index('ix_invoice_emails_status', table_name='invoice_emails')
op.drop_index('ix_invoice_emails_recipient_email', table_name='invoice_emails')
op.drop_index('ix_invoice_emails_invoice_id', table_name='invoice_emails')
op.drop_table('invoice_emails')
# Drop recurring_invoices table
op.drop_index('ix_recurring_invoices_is_active', table_name='recurring_invoices')
op.drop_index('ix_recurring_invoices_next_run_date', table_name='recurring_invoices')
op.drop_index('ix_recurring_invoices_client_id', table_name='recurring_invoices')
op.drop_index('ix_recurring_invoices_project_id', table_name='recurring_invoices')
op.drop_table('recurring_invoices')