565 lines
18 KiB
Python
565 lines
18 KiB
Python
"""
|
|
Celery tasks for Invoice module.
|
|
|
|
Tasks include:
|
|
- Email notifications
|
|
- Recurring invoice generation
|
|
- Overdue invoice checking
|
|
- Payment reminders
|
|
- Estimate expiry checking
|
|
"""
|
|
|
|
import datetime
|
|
import logging
|
|
|
|
from celery import shared_task
|
|
from django.conf import settings
|
|
from django.core.mail import EmailMessage
|
|
from django.template.loader import render_to_string
|
|
from django.utils import timezone
|
|
|
|
from common.models import Profile
|
|
from common.tasks import set_rls_context
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@shared_task
|
|
def send_email(invoice_id, recipients, org_id, domain="localhost", protocol="http"):
|
|
"""
|
|
Send notification email when invoice is assigned to users.
|
|
|
|
Args:
|
|
invoice_id: UUID of the invoice
|
|
recipients: List of user profile IDs
|
|
org_id: UUID of the organization
|
|
domain: Domain for URL generation
|
|
protocol: HTTP or HTTPS
|
|
"""
|
|
from invoices.models import Invoice
|
|
|
|
set_rls_context(org_id)
|
|
invoice = Invoice.objects.filter(id=invoice_id).first()
|
|
if not invoice:
|
|
logger.warning(f"Invoice {invoice_id} not found")
|
|
return
|
|
|
|
for user_id in recipients:
|
|
profile = Profile.objects.filter(id=user_id, is_active=True).first()
|
|
if profile and profile.user and profile.user.email:
|
|
subject = f"Invoice #{invoice.invoice_number} has been assigned to you"
|
|
context = {
|
|
"invoice": invoice,
|
|
"invoice_title": invoice.invoice_title,
|
|
"invoice_number": invoice.invoice_number,
|
|
"url": f"{protocol}://{domain}/invoices/{invoice.id}",
|
|
"user": profile.user,
|
|
"assigned_by": invoice.created_by.user if invoice.created_by else None,
|
|
}
|
|
html_content = render_to_string(
|
|
"invoices/emails/assigned_to_email.html", context=context
|
|
)
|
|
msg = EmailMessage(
|
|
subject=subject,
|
|
body=html_content,
|
|
to=[profile.user.email],
|
|
)
|
|
msg.content_subtype = "html"
|
|
try:
|
|
msg.send()
|
|
logger.info(
|
|
f"Sent assignment email for invoice {invoice_id} to {profile.user.email}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send email: {e}")
|
|
|
|
|
|
@shared_task
|
|
def send_invoice_to_client(
|
|
invoice_id, org_id, domain="localhost", protocol="http", include_pdf=True
|
|
):
|
|
"""
|
|
Send invoice to client via email with optional PDF attachment.
|
|
|
|
Args:
|
|
invoice_id: UUID of the invoice
|
|
org_id: UUID of the organization
|
|
domain: Domain for URL generation
|
|
protocol: HTTP or HTTPS
|
|
include_pdf: Whether to attach PDF
|
|
"""
|
|
from invoices.models import Invoice
|
|
from invoices.pdf import generate_invoice_filename, generate_invoice_pdf
|
|
|
|
set_rls_context(org_id)
|
|
invoice = Invoice.objects.filter(id=invoice_id).first()
|
|
if not invoice:
|
|
logger.warning(f"Invoice {invoice_id} not found")
|
|
return
|
|
|
|
if not invoice.client_email:
|
|
logger.warning(f"Invoice {invoice_id} has no client email")
|
|
return
|
|
|
|
subject = f"Invoice #{invoice.invoice_number} from {invoice.org.name}"
|
|
|
|
# Build public URL if enabled
|
|
public_url = None
|
|
if invoice.public_link_enabled and invoice.public_token:
|
|
public_url = f"{protocol}://{domain}/portal/invoice/{invoice.public_token}"
|
|
|
|
context = {
|
|
"invoice": invoice,
|
|
"public_url": public_url,
|
|
"org": invoice.org,
|
|
}
|
|
html_content = render_to_string(
|
|
"invoices/emails/invoice_to_client.html", context=context
|
|
)
|
|
|
|
msg = EmailMessage(
|
|
subject=subject,
|
|
body=html_content,
|
|
to=[invoice.client_email],
|
|
)
|
|
msg.content_subtype = "html"
|
|
|
|
# Attach PDF if requested
|
|
if include_pdf:
|
|
try:
|
|
pdf_content = generate_invoice_pdf(invoice)
|
|
filename = generate_invoice_filename(invoice)
|
|
msg.attach(filename, pdf_content, "application/pdf")
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate PDF for invoice {invoice_id}: {e}")
|
|
|
|
try:
|
|
msg.send()
|
|
# Update invoice
|
|
invoice.is_email_sent = True
|
|
invoice.sent_at = timezone.now()
|
|
if invoice.status == "Draft":
|
|
invoice.status = "Sent"
|
|
invoice.save()
|
|
logger.info(f"Sent invoice {invoice_id} to {invoice.client_email}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send invoice email: {e}")
|
|
|
|
|
|
@shared_task
|
|
def create_invoice_history(invoice_id, updated_by_user_id, changed_fields, org_id):
|
|
"""
|
|
Create a history record for invoice changes.
|
|
|
|
Args:
|
|
invoice_id: UUID of the invoice
|
|
updated_by_user_id: UUID of the profile who made changes
|
|
changed_fields: List of field names that changed
|
|
org_id: UUID of the organization
|
|
"""
|
|
from invoices.models import Invoice, InvoiceHistory
|
|
|
|
set_rls_context(org_id)
|
|
invoice = Invoice.objects.filter(id=invoice_id).first()
|
|
if not invoice:
|
|
logger.warning(f"Invoice {invoice_id} not found")
|
|
return
|
|
|
|
updated_by = Profile.objects.filter(id=updated_by_user_id).first()
|
|
|
|
# Format changed fields message
|
|
if changed_fields:
|
|
changed_data = [
|
|
(" ".join(field.split("_")).title()) for field in changed_fields
|
|
]
|
|
if len(changed_data) > 1:
|
|
changed_data = (
|
|
", ".join(changed_data[:-1])
|
|
+ " and "
|
|
+ changed_data[-1]
|
|
+ " have changed."
|
|
)
|
|
elif len(changed_data) == 1:
|
|
changed_data = changed_data[0] + " has changed."
|
|
else:
|
|
changed_data = None
|
|
else:
|
|
changed_data = None
|
|
|
|
# First history entry
|
|
if invoice.invoice_history.count() == 0:
|
|
changed_data = "Invoice created."
|
|
|
|
InvoiceHistory.objects.create(
|
|
invoice=invoice,
|
|
invoice_title=invoice.invoice_title,
|
|
invoice_number=invoice.invoice_number,
|
|
total_amount=invoice.total_amount,
|
|
currency=invoice.currency,
|
|
updated_by=updated_by,
|
|
created_by=invoice.created_by,
|
|
amount_due=invoice.amount_due,
|
|
client_name=invoice.client_name,
|
|
client_email=invoice.client_email,
|
|
status=invoice.status,
|
|
details=changed_data,
|
|
due_date=invoice.due_date,
|
|
org=invoice.org,
|
|
)
|
|
logger.info(f"Created history entry for invoice {invoice_id}")
|
|
|
|
|
|
@shared_task
|
|
def generate_recurring_invoices():
|
|
"""
|
|
Generate invoices from active recurring invoice templates.
|
|
Should be scheduled to run daily.
|
|
"""
|
|
from invoices.models import Invoice, InvoiceLineItem, RecurringInvoice
|
|
|
|
logger.info("Starting recurring invoice generation")
|
|
today = timezone.now().date()
|
|
|
|
# Get all active recurring invoices due for generation
|
|
recurring_invoices = RecurringInvoice.objects.filter(
|
|
is_active=True,
|
|
next_generation_date__lte=today,
|
|
).select_related("org", "account", "contact")
|
|
|
|
for recurring in recurring_invoices:
|
|
# Check end date
|
|
if recurring.end_date and recurring.end_date < today:
|
|
recurring.is_active = False
|
|
recurring.save()
|
|
logger.info(
|
|
f"Deactivated recurring invoice {recurring.id} - end date reached"
|
|
)
|
|
continue
|
|
|
|
# Set RLS context for this org
|
|
set_rls_context(str(recurring.org.id))
|
|
|
|
try:
|
|
# Create new invoice
|
|
invoice = Invoice.objects.create(
|
|
invoice_title=recurring.title,
|
|
status="Draft" if not recurring.auto_send else "Sent",
|
|
account=recurring.account,
|
|
contact=recurring.contact,
|
|
client_name=recurring.client_name,
|
|
client_email=recurring.client_email,
|
|
discount_type=recurring.discount_type,
|
|
discount_value=recurring.discount_value,
|
|
tax_rate=recurring.tax_rate,
|
|
currency=recurring.currency,
|
|
issue_date=today,
|
|
payment_terms=recurring.payment_terms,
|
|
notes=recurring.notes,
|
|
terms=recurring.terms,
|
|
org=recurring.org,
|
|
)
|
|
|
|
# Copy line items
|
|
for item in recurring.line_items.all():
|
|
InvoiceLineItem.objects.create(
|
|
invoice=invoice,
|
|
product=item.product,
|
|
name=item.name,
|
|
description=item.description,
|
|
quantity=item.quantity,
|
|
unit_price=item.unit_price,
|
|
discount_type=item.discount_type,
|
|
discount_value=item.discount_value,
|
|
tax_rate=item.tax_rate,
|
|
order=item.order,
|
|
org=recurring.org,
|
|
)
|
|
|
|
# Recalculate totals
|
|
invoice.recalculate_totals()
|
|
invoice.save()
|
|
|
|
# Update recurring invoice
|
|
recurring.next_generation_date = recurring.calculate_next_date()
|
|
recurring.invoices_generated += 1
|
|
recurring.save()
|
|
|
|
logger.info(f"Created invoice {invoice.id} from recurring {recurring.id}")
|
|
|
|
# Auto-send if enabled
|
|
if recurring.auto_send:
|
|
send_invoice_to_client.delay(
|
|
str(invoice.id),
|
|
str(recurring.org.id),
|
|
domain=getattr(settings, "DOMAIN_NAME", "localhost"),
|
|
protocol="https"
|
|
if getattr(settings, "USE_HTTPS", False)
|
|
else "http",
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to generate invoice from recurring {recurring.id}: {e}"
|
|
)
|
|
|
|
logger.info("Finished recurring invoice generation")
|
|
|
|
|
|
@shared_task
|
|
def check_overdue_invoices():
|
|
"""
|
|
Mark invoices as overdue if past due date.
|
|
Should be scheduled to run daily.
|
|
"""
|
|
from invoices.models import Invoice
|
|
|
|
logger.info("Checking for overdue invoices")
|
|
today = timezone.now().date()
|
|
|
|
# Find invoices that are past due but not yet marked overdue
|
|
overdue_invoices = Invoice.objects.filter(
|
|
due_date__lt=today,
|
|
status__in=["Sent", "Viewed", "Partially_Paid"],
|
|
)
|
|
|
|
count = 0
|
|
for invoice in overdue_invoices:
|
|
set_rls_context(str(invoice.org.id))
|
|
invoice.status = "Overdue"
|
|
invoice.save()
|
|
count += 1
|
|
logger.info(f"Marked invoice {invoice.id} as overdue")
|
|
|
|
logger.info(f"Marked {count} invoices as overdue")
|
|
|
|
|
|
@shared_task
|
|
def send_payment_reminder(invoice_id, org_id, domain="localhost", protocol="http"):
|
|
"""
|
|
Send a payment reminder for an invoice.
|
|
|
|
Args:
|
|
invoice_id: UUID of the invoice
|
|
org_id: UUID of the organization
|
|
domain: Domain for URL generation
|
|
protocol: HTTP or HTTPS
|
|
"""
|
|
from invoices.models import Invoice
|
|
|
|
set_rls_context(org_id)
|
|
invoice = Invoice.objects.filter(id=invoice_id).first()
|
|
if not invoice:
|
|
logger.warning(f"Invoice {invoice_id} not found")
|
|
return
|
|
|
|
if not invoice.client_email:
|
|
logger.warning(f"Invoice {invoice_id} has no client email")
|
|
return
|
|
|
|
# Don't send reminders for paid or cancelled invoices
|
|
if invoice.status in ["Paid", "Cancelled"]:
|
|
logger.info(f"Skipping reminder for {invoice_id} - status is {invoice.status}")
|
|
return
|
|
|
|
subject = f"Payment Reminder: Invoice #{invoice.invoice_number}"
|
|
|
|
public_url = None
|
|
if invoice.public_link_enabled and invoice.public_token:
|
|
public_url = f"{protocol}://{domain}/portal/invoice/{invoice.public_token}"
|
|
|
|
context = {
|
|
"invoice": invoice,
|
|
"public_url": public_url,
|
|
"org": invoice.org,
|
|
"days_overdue": (timezone.now().date() - invoice.due_date).days
|
|
if invoice.due_date
|
|
else 0,
|
|
}
|
|
html_content = render_to_string(
|
|
"invoices/emails/payment_reminder.html", context=context
|
|
)
|
|
|
|
msg = EmailMessage(
|
|
subject=subject,
|
|
body=html_content,
|
|
to=[invoice.client_email],
|
|
)
|
|
msg.content_subtype = "html"
|
|
|
|
try:
|
|
msg.send()
|
|
invoice.last_reminder_sent = timezone.now()
|
|
invoice.reminder_count += 1
|
|
invoice.save()
|
|
logger.info(f"Sent payment reminder for invoice {invoice_id}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send payment reminder: {e}")
|
|
|
|
|
|
@shared_task
|
|
def process_payment_reminders():
|
|
"""
|
|
Process all invoices that need payment reminders.
|
|
Should be scheduled to run daily.
|
|
"""
|
|
from invoices.models import Invoice
|
|
|
|
logger.info("Processing payment reminders")
|
|
today = timezone.now().date()
|
|
|
|
# Get invoices with reminders enabled
|
|
invoices = (
|
|
Invoice.objects.filter(
|
|
reminder_enabled=True,
|
|
status__in=["Sent", "Viewed", "Partially_Paid", "Overdue"],
|
|
)
|
|
.exclude(client_email__isnull=True)
|
|
.exclude(client_email="")
|
|
)
|
|
|
|
for invoice in invoices:
|
|
set_rls_context(str(invoice.org.id))
|
|
|
|
# Check if we should send a reminder
|
|
should_send = False
|
|
days_until_due = (invoice.due_date - today).days if invoice.due_date else None
|
|
|
|
# Determine reminder interval based on frequency setting
|
|
reminder_interval_days = 7 # default
|
|
if invoice.reminder_frequency == "ONCE":
|
|
reminder_interval_days = 9999 # effectively only once
|
|
elif invoice.reminder_frequency == "WEEKLY":
|
|
reminder_interval_days = 7
|
|
|
|
# Before due date reminder
|
|
if days_until_due is not None and days_until_due > 0:
|
|
if (
|
|
invoice.reminder_days_before
|
|
and days_until_due <= invoice.reminder_days_before
|
|
):
|
|
# Check if we haven't sent a reminder recently
|
|
if not invoice.last_reminder_sent or (
|
|
(today - invoice.last_reminder_sent.date()).days
|
|
>= reminder_interval_days
|
|
):
|
|
should_send = True
|
|
|
|
# After due date reminder
|
|
elif days_until_due is not None and days_until_due < 0:
|
|
days_overdue = abs(days_until_due)
|
|
if (
|
|
invoice.reminder_days_after
|
|
and days_overdue >= invoice.reminder_days_after
|
|
):
|
|
# Check reminder frequency
|
|
if not invoice.last_reminder_sent or (
|
|
(today - invoice.last_reminder_sent.date()).days
|
|
>= reminder_interval_days
|
|
):
|
|
should_send = True
|
|
|
|
if should_send:
|
|
send_payment_reminder.delay(
|
|
str(invoice.id),
|
|
str(invoice.org.id),
|
|
domain=getattr(settings, "DOMAIN_NAME", "localhost"),
|
|
protocol="https" if getattr(settings, "USE_HTTPS", False) else "http",
|
|
)
|
|
|
|
logger.info("Finished processing payment reminders")
|
|
|
|
|
|
@shared_task
|
|
def check_expired_estimates():
|
|
"""
|
|
Mark estimates as expired if past valid_until date.
|
|
Should be scheduled to run daily.
|
|
"""
|
|
from invoices.models import Estimate
|
|
|
|
logger.info("Checking for expired estimates")
|
|
today = timezone.now().date()
|
|
|
|
# Find estimates that are past expiry_date but not yet marked expired
|
|
expired_estimates = Estimate.objects.filter(
|
|
expiry_date__lt=today,
|
|
status__in=["Draft", "Sent", "Viewed"],
|
|
)
|
|
|
|
count = 0
|
|
for estimate in expired_estimates:
|
|
set_rls_context(str(estimate.org.id))
|
|
estimate.status = "Expired"
|
|
estimate.save()
|
|
count += 1
|
|
logger.info(f"Marked estimate {estimate.id} as expired")
|
|
|
|
logger.info(f"Marked {count} estimates as expired")
|
|
|
|
|
|
@shared_task
|
|
def send_estimate_to_client(
|
|
estimate_id, org_id, domain="localhost", protocol="http", include_pdf=True
|
|
):
|
|
"""
|
|
Send estimate to client via email with optional PDF attachment.
|
|
|
|
Args:
|
|
estimate_id: UUID of the estimate
|
|
org_id: UUID of the organization
|
|
domain: Domain for URL generation
|
|
protocol: HTTP or HTTPS
|
|
include_pdf: Whether to attach PDF
|
|
"""
|
|
from invoices.models import Estimate
|
|
from invoices.pdf import generate_estimate_filename, generate_estimate_pdf
|
|
|
|
set_rls_context(org_id)
|
|
estimate = Estimate.objects.filter(id=estimate_id).first()
|
|
if not estimate:
|
|
logger.warning(f"Estimate {estimate_id} not found")
|
|
return
|
|
|
|
if not estimate.client_email:
|
|
logger.warning(f"Estimate {estimate_id} has no client email")
|
|
return
|
|
|
|
subject = f"Estimate #{estimate.estimate_number} from {estimate.org.name}"
|
|
|
|
public_url = None
|
|
if estimate.public_link_enabled and estimate.public_token:
|
|
public_url = f"{protocol}://{domain}/portal/estimate/{estimate.public_token}"
|
|
|
|
context = {
|
|
"estimate": estimate,
|
|
"public_url": public_url,
|
|
"org": estimate.org,
|
|
}
|
|
html_content = render_to_string(
|
|
"invoices/emails/estimate_to_client.html", context=context
|
|
)
|
|
|
|
msg = EmailMessage(
|
|
subject=subject,
|
|
body=html_content,
|
|
to=[estimate.client_email],
|
|
)
|
|
msg.content_subtype = "html"
|
|
|
|
if include_pdf:
|
|
try:
|
|
pdf_content = generate_estimate_pdf(estimate)
|
|
filename = generate_estimate_filename(estimate)
|
|
msg.attach(filename, pdf_content, "application/pdf")
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate PDF for estimate {estimate_id}: {e}")
|
|
|
|
try:
|
|
msg.send()
|
|
estimate.sent_at = timezone.now()
|
|
if estimate.status == "Draft":
|
|
estimate.status = "Sent"
|
|
estimate.save()
|
|
logger.info(f"Sent estimate {estimate_id} to {estimate.client_email}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send estimate email: {e}")
|