From 8356b6180137d2df20d079fceaa202113cdb989a Mon Sep 17 00:00:00 2001
From: Trey <73353716+TreyWW@users.noreply.github.com>
Date: Sat, 28 Sep 2024 19:57:46 +0100
Subject: [PATCH] Feature/email templates (#499)
Added basic email templates implementation, whole email system need revamp though
---
backend/api/base/modal.py | 43 ++++---
backend/api/emails/send.py | 59 +++++++---
.../api/invoices/create/set_destination.py | 2 +-
backend/api/invoices/edit.py | 1 +
backend/api/invoices/recurring/edit.py | 1 +
.../recurring/generate_next_invoice_now.py | 12 +-
backend/api/invoices/schedules/recurring.py | 0
backend/api/public/endpoints/Invoices/edit.py | 1 +
backend/api/public/permissions.py | 5 +
backend/api/settings/email_templates.py | 9 ++
backend/data/default_email_templates.py | 60 ++++++++++
...ing_invoices_invoice_cancelled_and_more.py | 36 ++++++
backend/models.py | 29 ++++-
backend/service/defaults/update.py | 1 +
.../service/invoices/common/create/create.py | 1 +
.../invoices/common/create/get_page.py | 4 +-
.../invoices/common/emails/on_create.py | 38 +++---
.../recurring/generation/next_invoice.py | 45 +++++++-
backend/service/invoices/single/create_url.py | 15 +++
.../service/invoices/single/get_invoice.py | 14 +++
backend/service/settings/view.py | 34 +++++-
backend/types/emails.py | 4 +-
backend/views/core/invoices/recurring/edit.py | 2 +
backend/views/core/invoices/single/edit.py | 3 +
.../core/invoices/single/manage_access.py | 41 +++----
backend/views/core/settings/view.py | 20 +++-
backend/webhooks/invoices/recurring.py | 4 +-
docs/user-guide/emails/templates/index.md | 42 +++++++
.../templates/base/topbar/+icon_dropdown.html | 2 +-
.../modals/invoices_to_destination.html | 8 ++
.../templates/modals/send_bulk_email.html | 23 +++-
.../templates/modals/send_single_email.html | 2 +-
.../create/destinations/_to_destination.html | 2 +
.../pages/invoices/dashboard/_fetch_body.html | 108 +++++++++---------
.../invoices/recurring/dashboard/manage.html | 2 +-
.../recurring/manage/next_invoice_block.html | 1 +
.../single/edit/edit_to_destination.html | 2 +
.../single/manage_access/_table_row.html | 14 ++-
.../single/manage_access/manage_access.html | 3 +-
frontend/templates/pages/settings/main.html | 17 ++-
.../pages/settings/pages/email_templates.html | 22 ++++
.../settings/email_templates/tabs.html | 60 ++++++++++
mkdocs.yml | 2 +
settings/helpers.py | 56 ++++++++-
tests/views/test_invoices.py | 1 +
45 files changed, 694 insertions(+), 157 deletions(-)
delete mode 100644 backend/api/invoices/schedules/recurring.py
create mode 100644 backend/api/settings/email_templates.py
create mode 100644 backend/data/default_email_templates.py
create mode 100644 backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py
create mode 100644 backend/service/invoices/single/create_url.py
create mode 100644 backend/service/invoices/single/get_invoice.py
create mode 100644 docs/user-guide/emails/templates/index.md
create mode 100644 frontend/templates/pages/settings/pages/email_templates.html
create mode 100644 frontend/templates/pages/settings/settings/email_templates/tabs.html
diff --git a/backend/api/base/modal.py b/backend/api/base/modal.py
index 55921e97c..34a22bdb5 100644
--- a/backend/api/base/modal.py
+++ b/backend/api/base/modal.py
@@ -6,7 +6,7 @@
from backend.api.public.permissions import SCOPE_DESCRIPTIONS
from backend.api.public.models import APIAuthToken
-from backend.models import Client, Receipt, User
+from backend.models import Client, Receipt, User, InvoiceURL
from backend.models import Invoice
from backend.models import QuotaLimit
from backend.models import Organization
@@ -67,18 +67,18 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value
if invoice.client_to:
context["to_name"] = invoice.client_to.name
context["to_company"] = invoice.client_to.company
+ context["to_email"] = invoice.client_to.email
context["to_address"] = invoice.client_to.address
- context["existing_client_id"] = invoice.client_to.id
- # context["to_city"] = invoice.client_to.city
- # context["to_county"] = invoice.client_to.county
- # context["to_country"] = invoice.client_to.country
+ context["existing_client_id"] = (
+ invoice.client_to.id
+ ) # context["to_city"] = invoice.client_to.city # context["to_county"] = invoice.client_to.county # context["to_country"] = invoice.client_to.country
else:
context["to_name"] = invoice.client_name
context["to_company"] = invoice.client_company
- context["to_address"] = invoice.client_address
- # context["to_city"] = invoice.client_city
- # context["to_county"] = invoice.client_county
- # context["to_country"] = invoice.client_country
+ context["to_email"] = invoice.client_email
+ context["to_address"] = (
+ invoice.client_address
+ ) # context["to_city"] = invoice.client_city # context["to_county"] = invoice.client_county # context["to_country"] = invoice.client_country
elif context_type == "edit_invoice_from":
invoice = context_value
try:
@@ -134,8 +134,7 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value
# above_quota_usage = False # quota_usage_check_under(request, "invoices-schedules", api=True, htmx=True)
- # if not isinstance(above_quota_usage, bool):
- # context["above_quota_usage"] = True
+ # if not isinstance(above_quota_usage, bool): # context["above_quota_usage"] = True
else:
context[context_type] = context_value
@@ -147,8 +146,26 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value
context["content_min_length"] = 64
quota = QuotaLimit.objects.prefetch_related("quota_overrides").get(slug="emails-email_character_count")
context["content_max_length"] = quota.get_quota_limit(user=request.user, quota_limit=quota)
- clients = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False)
- context["email_list"] = clients
+ context["email_list"] = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False).values_list("email", flat=True)
+
+ if context_type == "invoice_code_send":
+ invoice_url: InvoiceURL | None = InvoiceURL.objects.filter(uuid=context_value).prefetch_related("invoice").first()
+
+ if not invoice_url or not invoice_url.invoice.has_access(request.user):
+ messages.error(request, "You don't have access to this invoice")
+ return render(request, "base/toast.html", {"autohide": False})
+
+ context["invoice"] = invoice_url.invoice
+ context["selected_clients"] = [
+ invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email
+ for value in [
+ invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email
+ ]
+ if value is not None
+ ]
+
+ context["email_list"] = list(context["email_list"]) + context["selected_clients"]
+
elif modal_name == "invoices_to_destination":
if existing_client := request.GET.get("client"):
context["existing_client_id"] = existing_client
diff --git a/backend/api/emails/send.py b/backend/api/emails/send.py
index cc9fd5795..f20e4290d 100644
--- a/backend/api/emails/send.py
+++ b/backend/api/emails/send.py
@@ -4,6 +4,7 @@
from dataclasses import dataclass
from collections.abc import Iterator
+from string import Template
from django.contrib import messages
from django.core.exceptions import ValidationError
@@ -14,6 +15,7 @@
from django.views.decorators.http import require_POST
from mypy_boto3_sesv2.type_defs import BulkEmailEntryResultTypeDef
+from backend.data.default_email_templates import email_footer
from backend.decorators import feature_flag_check, web_require_scopes
from backend.decorators import htmx_only
from backend.models import Client
@@ -23,8 +25,9 @@
from backend.types.emails import (
BulkEmailEmailItem,
)
+from backend.types.requests import WebRequest
-from settings.helpers import send_email, send_templated_bulk_email
+from settings.helpers import send_email, send_templated_bulk_email, get_var
from backend.types.htmx import HtmxHttpRequest
@@ -41,7 +44,7 @@ class Invalid:
@htmx_only("emails:dashboard")
@feature_flag_check("areUserEmailsAllowed", status=True, api=True, htmx=True)
@web_require_scopes("emails:send", False, False, "emails:dashboard")
-def send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
+def send_single_email_view(request: WebRequest) -> HttpResponse:
# check_usage = False # quota_usage_check_under(request, "emails-single-count", api=True, htmx=True)
# if not isinstance(check_usage, bool):
# return check_usage
@@ -53,7 +56,7 @@ def send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
@htmx_only("emails:dashboard")
@feature_flag_check("areUserEmailsAllowed", status=True, api=True, htmx=True)
@web_require_scopes("emails:send", False, False, "emails:dashboard")
-def send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
+def send_bulk_email_view(request: WebRequest) -> HttpResponse:
# email_count = len(request.POST.getlist("emails")) - 1
# check_usage = quota_usage_check_under(request, "emails-single-count", add=email_count, api=True, htmx=True)
@@ -62,10 +65,12 @@ def send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
return _send_bulk_email_view(request)
-def _send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
+def _send_bulk_email_view(request: WebRequest) -> HttpResponse:
emails: list[str] = request.POST.getlist("emails")
subject: str = request.POST.get("subject", "")
message: str = request.POST.get("content", "")
+ cc_yourself = True if request.POST.get("cc_yourself") else False
+ bcc_yourself = True if request.POST.get("bcc_yourself") else False
if request.user.logged_in_as_team:
clients = Client.objects.filter(organization=request.user.logged_in_as_team, email__in=emails)
@@ -78,23 +83,48 @@ def _send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
messages.error(request, validated_bulk)
return render(request, "base/toast.html")
+ message += email_footer()
message_single_line_html = message.replace("\r\n", "
").replace("\n", "
")
email_list: list[BulkEmailEmailItem] = []
for email in emails:
- client = clients.get(email=email)
+ client = clients.filter(email=email).first()
+
+ email_data = {
+ "users_name": client.name.split()[0] if client else "User",
+ "first_name": client.name.split()[0] if client else "User",
+ "company_name": request.actor.name,
+ } # todo: add all variables from https://docs.myfinances.cloud/user-guide/emails/templates/
+
email_list.append(
BulkEmailEmailItem(
destination=email,
+ cc=[request.user.email] if cc_yourself else [],
+ bcc=[request.user.email] if bcc_yourself else [],
template_data={
- "users_name": client.name.split()[0],
- "content_text": message.format(users_name=client.name.split()[0]),
- "content_html": message_single_line_html.format(users_name=client.name.split()[0]),
+ "users_name": client.name.split()[0] if client else "User",
+ "content_text": Template(message).substitute(email_data),
+ "content_html": Template(message_single_line_html).substitute(email_data),
},
)
)
+ if get_var("DEBUG", "").lower() == "true":
+ print(
+ {
+ "email_list": email_list,
+ "template_name": "user_send_client_email",
+ "default_template_data": {
+ "sender_name": request.user.first_name or request.user.email,
+ "sender_id": request.user.id,
+ "subject": subject,
+ },
+ }
+ )
+ messages.success(request, f"Successfully emailed {len(email_list)} people.")
+ return render(request, "base/toast.html")
+
EMAIL_SENT = send_templated_bulk_email(
email_list=email_list,
template_name="user_send_client_email",
@@ -160,7 +190,7 @@ def _send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse:
return render(request, "base/toast.html")
-def _send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
+def _send_single_email_view(request: WebRequest) -> HttpResponse:
email: str = str(request.POST.get("email", "")).strip()
subject: str = request.POST.get("subject", "")
message: str = request.POST.get("content", "")
@@ -176,8 +206,11 @@ def _send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
messages.error(request, validated_single)
return render(request, "base/toast.html")
+ message += email_footer()
message_single_line_html = message.replace("\r\n", "
").replace("\n", "
")
+ email_data = {"company_name": request.actor.name}
+
EMAIL_SENT = send_email(
destination=email,
subject=subject,
@@ -187,8 +220,8 @@ def _send_single_email_view(request: HtmxHttpRequest) -> HttpResponse:
"subject": subject,
"sender_name": request.user.first_name or request.user.email,
"sender_id": request.user.id,
- "content_text": message,
- "content_html": message_single_line_html,
+ "content_text": Template(message).substitute(email_data),
+ "content_html": Template(message_single_line_html).substitute(email_data),
},
},
)
@@ -222,7 +255,7 @@ def validate_bulk_inputs(*, request, emails, clients, message, subject) -> str |
def run_validations():
yield validate_bulk_quotas(request=request, emails=emails)
yield validate_email_list(emails=emails)
- yield validate_client_list(clients=clients, emails=emails)
+ # yield validate_client_list(clients=clients, emails=emails)
yield validate_email_content(message=message, request=request)
yield validate_email_subject(subject=subject)
@@ -306,8 +339,6 @@ def validate_email_list(emails: list[str]) -> str | None:
def validate_client_list(clients: QuerySet[Client], emails: list[str]) -> str | None:
for email in emails:
if not clients.filter(email=email).exists():
- # if not client.email_verified:
- # return f"Client {email} isn't yet verified so we can't send them an email yet!"
return f"Could not find client object for {email}"
return None
diff --git a/backend/api/invoices/create/set_destination.py b/backend/api/invoices/create/set_destination.py
index 427854f75..cd19ba25f 100644
--- a/backend/api/invoices/create/set_destination.py
+++ b/backend/api/invoices/create/set_destination.py
@@ -5,7 +5,7 @@
from backend.models import Client
from backend.types.htmx import HtmxHttpRequest
-to_get = ["name", "address", "city", "country", "company", "is_representative"]
+to_get = ["name", "address", "city", "country", "company", "is_representative", "email"]
@require_http_methods(["POST"])
diff --git a/backend/api/invoices/edit.py b/backend/api/invoices/edit.py
index 11f8b025f..f6cef5256 100644
--- a/backend/api/invoices/edit.py
+++ b/backend/api/invoices/edit.py
@@ -34,6 +34,7 @@ def edit_invoice(request: HtmxHttpRequest):
"date_issued": request.POST.get("date_issued"),
"client_name": request.POST.get("to_name"),
"client_company": request.POST.get("to_company"),
+ "client_email": request.POST.get("to_email"),
"client_address": request.POST.get("to_address"),
"client_city": request.POST.get("to_city"),
"client_county": request.POST.get("to_county"),
diff --git a/backend/api/invoices/recurring/edit.py b/backend/api/invoices/recurring/edit.py
index 8cd3f15c7..351c16561 100644
--- a/backend/api/invoices/recurring/edit.py
+++ b/backend/api/invoices/recurring/edit.py
@@ -41,6 +41,7 @@ def edit_invoice_recurring_profile_endpoint(request: WebRequest, invoice_profile
"date_issued": request.POST.get("date_issued"),
"client_name": request.POST.get("to_name"),
"client_company": request.POST.get("to_company"),
+ "client_email": request.POST.get("to_email"),
"client_address": request.POST.get("to_address"),
"client_city": request.POST.get("to_city"),
"client_county": request.POST.get("to_county"),
diff --git a/backend/api/invoices/recurring/generate_next_invoice_now.py b/backend/api/invoices/recurring/generate_next_invoice_now.py
index b6e46c0e5..58cb77abf 100644
--- a/backend/api/invoices/recurring/generate_next_invoice_now.py
+++ b/backend/api/invoices/recurring/generate_next_invoice_now.py
@@ -5,7 +5,7 @@
from backend.decorators import web_require_scopes, htmx_only
from backend.models import InvoiceRecurringProfile, Invoice
from backend.service.defaults.get import get_account_defaults
-from backend.service.invoices.recurring.generation.next_invoice import generate_next_invoice_service
+from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service
from backend.types.requests import WebRequest
import logging
@@ -24,7 +24,7 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id):
if not invoice_recurring_profile:
messages.error(request, "Failed to fetch next invoice; cannot find Invoice recurring profile.")
- return render(request, "base/toast.html")
+ return render(request, "base/toast.html", {"autohide": False})
if invoice_recurring_profile.client_to:
account_defaults = get_account_defaults(invoice_recurring_profile.owner, invoice_recurring_profile.client_to)
@@ -33,11 +33,11 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id):
if not invoice_recurring_profile.has_access(request.user):
messages.error(request, "You do not have permission to modify this invoice recurring profile.")
- return render(request, "base/toast.html")
+ return render(request, "base/toast.html", {"autohide": False})
next_invoice_issue_date = invoice_recurring_profile.next_invoice_issue_date()
- svc_resp = generate_next_invoice_service(
+ svc_resp = safe_generate_next_invoice_service(
invoice_recurring_profile=invoice_recurring_profile, issue_date=next_invoice_issue_date, account_defaults=account_defaults
)
@@ -58,5 +58,5 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id):
)
else:
logger.info(svc_resp.error)
- messages.error(request, "Failed to fetch next invoice; cannot find invoice recurring profile.")
- return render(request, "base/toast.html")
+ messages.error(request, f"Failed to fetch next invoice; {svc_resp.error}")
+ return render(request, "base/toast.html", {"autohide": False})
diff --git a/backend/api/invoices/schedules/recurring.py b/backend/api/invoices/schedules/recurring.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/backend/api/public/endpoints/Invoices/edit.py b/backend/api/public/endpoints/Invoices/edit.py
index 5ac882f84..42abcf0c7 100644
--- a/backend/api/public/endpoints/Invoices/edit.py
+++ b/backend/api/public/endpoints/Invoices/edit.py
@@ -36,6 +36,7 @@ def edit_invoice_endpoint(request: APIRequest):
"date_issued": request.POST.get("date_issued"),
"client_name": request.POST.get("to_name"),
"client_company": request.POST.get("to_company"),
+ "client_email": request.POST.get("to_email"),
"client_address": request.POST.get("to_address"),
"client_city": request.POST.get("to_city"),
"client_county": request.POST.get("to_county"),
diff --git a/backend/api/public/permissions.py b/backend/api/public/permissions.py
index c95b49657..69beaa4b2 100644
--- a/backend/api/public/permissions.py
+++ b/backend/api/public/permissions.py
@@ -21,6 +21,8 @@
"team_permissions:write",
"team:invite",
"team:kick",
+ "email_templates:read",
+ "email_templates:write",
}
SCOPES_TREE = {
@@ -36,6 +38,8 @@
"team_permissions:write": {"team_permissions:read", "team_permissions:write"},
"team:invite": {"team:invite"},
"team:kick": {"team:kick", "team:invite"},
+ "email_templates:read": {"email_templates:read"},
+ "email_templates:write": {"email_templates:read", "email_templates:write"},
}
SCOPE_DESCRIPTIONS = {
@@ -45,6 +49,7 @@
"api_keys": {"description": "Access API keys", "options": {"read": "Read only", "write": "Read and write"}},
"team_permissions": {"description": "Access team permissions", "options": {"read": "Read only", "write": "Read and write"}},
"team": {"description": "Invite team members", "options": {"invite": "Invite members"}},
+ "email_templates": {"description": "Access email templates", "options": {"read": "Read only", "write": "Read and write"}},
}
if settings.BILLING_ENABLED:
diff --git a/backend/api/settings/email_templates.py b/backend/api/settings/email_templates.py
new file mode 100644
index 000000000..b28c8e64c
--- /dev/null
+++ b/backend/api/settings/email_templates.py
@@ -0,0 +1,9 @@
+from django.views.decorators.http import require_GET
+
+from backend.decorators import web_require_scopes
+from backend.types.requests import WebRequest
+
+
+@require_GET
+@web_require_scopes("email_templates:read")
+def get_current_email_template(request: WebRequest): ...
diff --git a/backend/data/default_email_templates.py b/backend/data/default_email_templates.py
new file mode 100644
index 000000000..03c431c5f
--- /dev/null
+++ b/backend/data/default_email_templates.py
@@ -0,0 +1,60 @@
+from textwrap import dedent
+
+
+def recurring_invoices_invoice_created_default_email_template() -> str:
+ return dedent(
+ """
+ Hi $first_name,
+
+ The invoice #$invoice_id has been created for you to pay, due on the $due_date. Please pay at your earliest convenience.
+
+ Balance Due: $currency_symbol$amount_due $currency
+
+ Many thanks,
+ $company_name
+ """
+ ).strip()
+
+
+def recurring_invoices_invoice_overdue_default_email_template() -> str:
+ return dedent(
+ """
+ Hi $first_name,
+
+ The invoice #$invoice_id is now overdue. Please pay as soon as possible to avoid any interruptions in your service or late fees.
+
+ Balance Due: $currency_symbol$amount_due $currency
+
+ Many thanks,
+ $company_name
+ """
+ ).strip()
+
+
+def recurring_invoices_invoice_cancelled_default_email_template() -> str:
+ return dedent(
+ """
+ Hi $first_name,
+
+ The invoice #$invoice_id has been cancelled. You do not have to pay the invoice.
+
+ If you have any questions or concerns, please feel free to contact us.
+
+ Many thanks,
+ $company_name
+ """
+ ).strip()
+
+
+def email_footer() -> str:
+ return (
+ "\n"
+ + dedent(
+ """
+Note: This is an automated email sent out by MyFinances on behalf of '$company_name'.
+
+If you believe this is spam or fraudulent please report it to us at report@myfinances.cloud and DO NOT pay the invoice.
+Once a report has been made you will have a case opened. Eligible reports may receive a reward, decided on a case by case basis.
+"""
+ ).strip()
+ )
diff --git a/backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py b/backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py
new file mode 100644
index 000000000..5aefa7ed3
--- /dev/null
+++ b/backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py
@@ -0,0 +1,36 @@
+# Generated by Django 5.1 on 2024-09-28 18:46
+
+import backend.data.default_email_templates
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("backend", "0062_defaultvalues_invoice_account_holder_name_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="defaultvalues",
+ name="email_template_recurring_invoices_invoice_cancelled",
+ field=models.TextField(
+ default=backend.data.default_email_templates.recurring_invoices_invoice_cancelled_default_email_template
+ ),
+ ),
+ migrations.AddField(
+ model_name="defaultvalues",
+ name="email_template_recurring_invoices_invoice_created",
+ field=models.TextField(default=backend.data.default_email_templates.recurring_invoices_invoice_created_default_email_template),
+ ),
+ migrations.AddField(
+ model_name="defaultvalues",
+ name="email_template_recurring_invoices_invoice_overdue",
+ field=models.TextField(default=backend.data.default_email_templates.recurring_invoices_invoice_overdue_default_email_template),
+ ),
+ migrations.AddField(
+ model_name="defaultvalues",
+ name="invoice_from_email",
+ field=models.CharField(blank=True, max_length=100, null=True),
+ ),
+ ]
diff --git a/backend/models.py b/backend/models.py
index 3a8e6e2f4..60d7117fb 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -19,6 +19,12 @@
from shortuuid.django_fields import ShortUUIDField
from storages.backends.s3 import S3Storage
+from backend.data.default_email_templates import (
+ recurring_invoices_invoice_created_default_email_template,
+ recurring_invoices_invoice_overdue_default_email_template,
+ recurring_invoices_invoice_cancelled_default_email_template,
+)
+
from backend.managers import InvoiceRecurringProfile_WithItemsManager
@@ -95,6 +101,10 @@ class Role(models.TextChoices):
role = models.CharField(max_length=10, choices=Role.choices, default=Role.USER)
+ @property
+ def name(self):
+ return self.first_name
+
def add_3hrs_from_now():
return timezone.now() + timezone.timedelta(hours=3)
@@ -406,11 +416,18 @@ class InvoiceDateType(models.TextChoices):
invoice_from_city = models.CharField(max_length=100, null=True, blank=True)
invoice_from_county = models.CharField(max_length=100, null=True, blank=True)
invoice_from_country = models.CharField(max_length=100, null=True, blank=True)
+ invoice_from_email = models.CharField(max_length=100, null=True, blank=True)
invoice_account_number = models.CharField(max_length=100, null=True, blank=True)
invoice_sort_code = models.CharField(max_length=100, null=True, blank=True)
invoice_account_holder_name = models.CharField(max_length=100, null=True, blank=True)
+ email_template_recurring_invoices_invoice_created = models.TextField(default=recurring_invoices_invoice_created_default_email_template)
+ email_template_recurring_invoices_invoice_overdue = models.TextField(default=recurring_invoices_invoice_overdue_default_email_template)
+ email_template_recurring_invoices_invoice_cancelled = models.TextField(
+ default=recurring_invoices_invoice_cancelled_default_email_template
+ )
+
def get_issue_and_due_dates(self, issue_date: date | str | None = None) -> tuple[str, str]:
due: date
issue: date
@@ -606,10 +623,7 @@ def get_to_details(self) -> tuple[str, dict[str, str | None]] | tuple[str, Clien
if self.client_to:
return "client", self.client_to
else:
- return "manual", {
- "name": self.client_name,
- "company": self.client_company,
- }
+ return "manual", {"name": self.client_name, "company": self.client_company, "email": self.client_email}
def get_subtotal(self) -> Decimal:
subtotal = 0
@@ -723,6 +737,13 @@ class InvoiceURL(models.Model):
never_expire = models.BooleanField(default=False)
active = models.BooleanField(default=True)
+ @property
+ def get_created_by(self):
+ if self.created_by:
+ return self.created_by.first_name or f"USR #{self.created_by.id}"
+ else:
+ return "SYSTEM"
+
def is_active(self):
if not self.active:
return False
diff --git a/backend/service/defaults/update.py b/backend/service/defaults/update.py
index 10dea69ed..176182473 100644
--- a/backend/service/defaults/update.py
+++ b/backend/service/defaults/update.py
@@ -46,6 +46,7 @@ def change_client_defaults(request: WebRequest, defaults: DefaultValues) -> Clie
DETAIL_INPUTS = {
"name": {"max_len": 100},
+ "email": {"max_len": 100},
"company": {"max_len": 100},
"address": {"max_len": 100},
"city": {"max_len": 100},
diff --git a/backend/service/invoices/common/create/create.py b/backend/service/invoices/common/create/create.py
index cfcf48c32..fbd0bf584 100644
--- a/backend/service/invoices/common/create/create.py
+++ b/backend/service/invoices/common/create/create.py
@@ -37,6 +37,7 @@ def save_invoice_common(request: WebRequest, invoice_items, invoice: Invoice | I
else:
invoice.client_name = request.POST.get("to_name")
invoice.client_company = request.POST.get("to_company")
+ invoice.client_email = request.POST.get("to_email")
invoice.client_address = request.POST.get("to_address")
invoice.client_city = request.POST.get("to_city")
invoice.client_county = request.POST.get("to_county")
diff --git a/backend/service/invoices/common/create/get_page.py b/backend/service/invoices/common/create/get_page.py
index 2bd1ba023..810b22b2f 100644
--- a/backend/service/invoices/common/create/get_page.py
+++ b/backend/service/invoices/common/create/get_page.py
@@ -36,7 +36,7 @@ def global_get_invoice_context(request: WebRequest) -> CreateInvoiceContextServi
else:
defaults = get_account_defaults(request.actor, client=None)
- for item in ["name", "company", "address", "city", "county", "country"]:
+ for item in ["name", "company", "address", "city", "county", "country", "email"]:
context[f"from_{item}"] = request.GET.get(f"from_{item}", "")
if issue_date := request.GET.get("issue_date"):
@@ -72,7 +72,7 @@ def global_get_invoice_context(request: WebRequest) -> CreateInvoiceContextServi
if account_number := request.GET.get("account_number"):
context["account_number"] = account_number
- details_from = ["name", "company", "address", "city", "county", "country"]
+ details_from = ["name", "company", "address", "city", "county", "country", "email"]
for detail in details_from:
detail_value = request.GET.get(f"from_{detail}", "")
diff --git a/backend/service/invoices/common/emails/on_create.py b/backend/service/invoices/common/emails/on_create.py
index d2ab88c0e..50b0243ea 100644
--- a/backend/service/invoices/common/emails/on_create.py
+++ b/backend/service/invoices/common/emails/on_create.py
@@ -1,34 +1,37 @@
from string import Template
from textwrap import dedent
-from backend.models import Invoice, InvoiceRecurringProfile, User, EmailSendStatus
+from django.urls import reverse
+
+from backend.data.default_email_templates import email_footer
+from backend.models import Invoice, InvoiceRecurringProfile, User, EmailSendStatus, InvoiceURL
+from backend.service.defaults.get import get_account_defaults
+from backend.service.invoices.single.create_url import create_invoice_url
from backend.utils.dataclasses import BaseServiceResponse
from backend.utils.service_retry import retry_handler
-from settings.helpers import send_email
+from settings.helpers import send_email, get_var
+
+"""
+DOCS: https://docs.myfinances.cloud/user-guide/emails/templates/
+(please update if any variables are changed)
+"""
-class OnCreateInvoiceEmailServiceResponse(BaseServiceResponse[str]): ...
+class OnCreateInvoiceEmailServiceResponse(BaseServiceResponse[EmailSendStatus]): ...
-def on_create_invoice_service(users_email: str, invoice: Invoice) -> OnCreateInvoiceEmailServiceResponse:
+def on_create_invoice_email_service(users_email: str, invoice: Invoice) -> OnCreateInvoiceEmailServiceResponse:
if not users_email:
return OnCreateInvoiceEmailServiceResponse(error_message="User email not found")
if not invoice:
return OnCreateInvoiceEmailServiceResponse(error_message="Invoice not found")
- email_message = dedent(
- """
- Hi $first_name,
+ defaults = get_account_defaults(invoice.owner, invoice.client_to)
- The invoice #$invoice_id has been created for you to pay, due on the $due_date. Please pay at your earliest convenience.
+ email_message: str = defaults.email_template_recurring_invoices_invoice_created + email_footer()
- Balance Due: $currency_symbol$amount_due $currency
-
- Many thanks,
- $company_name
- """
- )
+ invoice_url: InvoiceURL = create_invoice_url(invoice).response
user_data = {
"first_name": invoice.client_to.name.split(" ")[0] if invoice.client_to else invoice.client_name,
@@ -39,7 +42,8 @@ def on_create_invoice_service(users_email: str, invoice: Invoice) -> OnCreateInv
"currency": invoice.currency,
"currency_symbol": invoice.get_currency_symbol(),
"product_list": [], # todo
- "company_name": invoice.self_company or invoice.self_name,
+ "company_name": invoice.self_company or invoice.self_name or "MyFinances Customer",
+ "invoice_link": get_var("SITE_URL") + reverse("invoices view invoice", kwargs={"uuid": str(invoice_url.uuid)}),
}
output: str = Template(email_message).substitute(user_data)
@@ -54,11 +58,11 @@ def on_create_invoice_service(users_email: str, invoice: Invoice) -> OnCreateInv
if email_svc_response.failed:
return OnCreateInvoiceEmailServiceResponse(False, error_message="Failed to send email")
- EmailSendStatus.objects.create(
+ email_status_obj = EmailSendStatus.objects.create(
status="send",
owner=invoice.owner,
recipient=users_email,
aws_message_id=email_svc_response.response.get("MessageId"),
)
- return OnCreateInvoiceEmailServiceResponse(True, response="Email sent successfully")
+ return OnCreateInvoiceEmailServiceResponse(True, response=email_status_obj)
diff --git a/backend/service/invoices/recurring/generation/next_invoice.py b/backend/service/invoices/recurring/generation/next_invoice.py
index 4c7476486..21f21299f 100644
--- a/backend/service/invoices/recurring/generation/next_invoice.py
+++ b/backend/service/invoices/recurring/generation/next_invoice.py
@@ -1,6 +1,10 @@
from datetime import datetime, date, timedelta
+
+from django.db import transaction, IntegrityError
+
from backend.models import Invoice, InvoiceRecurringProfile, DefaultValues, AuditLog
from backend.service.defaults.get import get_account_defaults
+from backend.service.invoices.common.emails.on_create import on_create_invoice_email_service
from backend.utils.dataclasses import BaseServiceResponse
import logging
@@ -11,6 +15,7 @@
class GenerateNextInvoiceServiceResponse(BaseServiceResponse[Invoice]): ...
+@transaction.atomic
def generate_next_invoice_service(
invoice_recurring_profile: InvoiceRecurringProfile,
issue_date: date = date.today(),
@@ -76,10 +81,48 @@ def generate_next_invoice_service(
logger.info(f"Invoice generated with the ID of {generated_invoice.pk}")
+ users_email: str = (
+ invoice_recurring_profile.client_to.email if invoice_recurring_profile.client_to else invoice_recurring_profile.client_email
+ ) or ""
+
+ invoice_email_response = on_create_invoice_email_service(users_email=users_email, invoice=generated_invoice)
+
+ if invoice_email_response.failed:
+ print("here bef fail")
+ raise IntegrityError(f"Failed to send invoice #{generated_invoice.pk} to {users_email}: {invoice_email_response.error}")
+
AuditLog.objects.create(
action=f"[SYSTEM] Generated invoice #{generated_invoice.pk} from the recurring profile #{invoice_recurring_profile.pk}",
user=invoice_recurring_profile.user,
organization=invoice_recurring_profile.organization,
)
- return GenerateNextInvoiceServiceResponse(True, generated_invoice)
+ return GenerateNextInvoiceServiceResponse(True, response=generated_invoice)
+
+
+def handle_invoice_generation_failure(invoice_recurring_profile, error_message):
+ """
+ Function to handle invoice generation failure and log it in AuditLog.
+ This runs outside the atomic transaction to avoid rollback.
+ """
+ AuditLog.objects.create(
+ action=f"[SYSTEM] Failed to generate invoice for recurring profile #{invoice_recurring_profile.pk}. Error: {error_message}",
+ )
+ logger.error(f"Failed to generate invoice for profile {invoice_recurring_profile.pk}: {error_message}")
+
+
+def safe_generate_next_invoice_service(
+ invoice_recurring_profile: InvoiceRecurringProfile,
+ issue_date: date = date.today(),
+ account_defaults: DefaultValues | None = None,
+) -> GenerateNextInvoiceServiceResponse:
+ """
+ Safe wrapper to generate the next invoice with transaction rollback and error logging.
+ """
+ try:
+ # Call the main service function wrapped with @transaction.atomic
+ return generate_next_invoice_service(invoice_recurring_profile, issue_date, account_defaults)
+ except Exception as e:
+ # Handle the error and ensure the failure is logged
+ handle_invoice_generation_failure(invoice_recurring_profile, str(e))
+ return GenerateNextInvoiceServiceResponse(False, error_message=str(e))
diff --git a/backend/service/invoices/single/create_url.py b/backend/service/invoices/single/create_url.py
new file mode 100644
index 000000000..81234bdba
--- /dev/null
+++ b/backend/service/invoices/single/create_url.py
@@ -0,0 +1,15 @@
+from backend.models import InvoiceURL, Invoice, User
+from backend.utils.dataclasses import BaseServiceResponse
+
+
+class CreateInvoiceURLServiceResponse(BaseServiceResponse[InvoiceURL]): ...
+
+
+def create_invoice_url(invoice: Invoice, user: User | None = None) -> CreateInvoiceURLServiceResponse:
+ return CreateInvoiceURLServiceResponse(
+ True,
+ response=InvoiceURL.objects.create(
+ invoice=invoice,
+ created_by=user,
+ ),
+ )
diff --git a/backend/service/invoices/single/get_invoice.py b/backend/service/invoices/single/get_invoice.py
new file mode 100644
index 000000000..80c58869e
--- /dev/null
+++ b/backend/service/invoices/single/get_invoice.py
@@ -0,0 +1,14 @@
+from backend.models import Invoice, Organization, User
+from backend.utils.dataclasses import BaseServiceResponse
+
+
+class GetInvoiceServiceResponse(BaseServiceResponse[Invoice]): ...
+
+
+def get_invoice_by_actor(actor: User | Organization, id: str | int, prefetch_related: list[str] | None = None) -> GetInvoiceServiceResponse:
+ prefetch_related_args: list[str] = prefetch_related or []
+ try:
+ invoice: Invoice = Invoice.filter_by_owner(actor).prefetch_related(*prefetch_related_args).get(id=id)
+ return GetInvoiceServiceResponse(True, response=invoice)
+ except Invoice.DoesNotExist:
+ return GetInvoiceServiceResponse(False, error_message="Invoice not found")
diff --git a/backend/service/settings/view.py b/backend/service/settings/view.py
index 1bd93259c..496ec8d4e 100644
--- a/backend/service/settings/view.py
+++ b/backend/service/settings/view.py
@@ -3,11 +3,12 @@
from backend.models import UserSettings
from backend.models import DefaultValues
from backend.api.public.models import APIAuthToken
+from backend.service.defaults.get import get_account_defaults
from backend.types.requests import WebRequest
def validate_page(page: str | None) -> bool:
- return not page or page in ["profile", "account", "api_keys", "account_defaults", "account_security"]
+ return not page or page in ["profile", "account", "api_keys", "account_defaults", "account_security", "email_templates"]
def get_user_profile(request: WebRequest) -> UserSettings:
@@ -21,3 +22,34 @@ def get_user_profile(request: WebRequest) -> UserSettings:
def get_api_keys(request: WebRequest) -> QuerySet[APIAuthToken]:
return APIAuthToken.filter_by_owner(request.actor).filter(active=True).only("created", "name", "last_used", "description", "expires")
+
+
+def account_page_context(request: WebRequest, context: dict) -> None:
+ user_profile = get_user_profile(request)
+ context.update({"currency_signs": user_profile.CURRENCIES, "currency": user_profile.currency})
+
+
+def api_keys_page_context(request: WebRequest, context: dict) -> None:
+ api_keys = get_api_keys(request)
+ context.update({"api_keys": api_keys})
+
+
+def account_defaults_context(request: WebRequest, context: dict) -> None:
+ context.update({"account_defaults": get_account_defaults(request.actor)})
+
+
+def email_templates_context(request: WebRequest, context: dict) -> None:
+ acc_defaults = get_account_defaults(request.actor)
+ context.update(
+ {
+ "account_defaults": acc_defaults,
+ "email_templates": {
+ "recurring_invoices": {
+ "invoice_created": acc_defaults.email_template_recurring_invoices_invoice_created,
+ "invoice_overdue": acc_defaults.email_template_recurring_invoices_invoice_overdue,
+ "invoice_cancelled": acc_defaults.email_template_recurring_invoices_invoice_cancelled,
+ }
+ },
+ }
+ )
+ print(context.get("email_templates"))
diff --git a/backend/types/emails.py b/backend/types/emails.py
index 3263acfeb..1e80d268a 100644
--- a/backend/types/emails.py
+++ b/backend/types/emails.py
@@ -1,4 +1,4 @@
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import TypedDict
from mypy_boto3_sesv2.type_defs import SendEmailResponseTypeDef, SendBulkEmailResponseTypeDef, BulkEmailEntryResultTypeDef
@@ -31,6 +31,8 @@ class SingleEmailInput:
class BulkEmailEmailItem:
destination: str
template_data: dict | str
+ cc: list[str] = field(default_factory=list)
+ bcc: list[str] = field(default_factory=list)
@dataclass(frozen=False)
diff --git a/backend/views/core/invoices/recurring/edit.py b/backend/views/core/invoices/recurring/edit.py
index e18fa6e64..6b85a72ea 100644
--- a/backend/views/core/invoices/recurring/edit.py
+++ b/backend/views/core/invoices/recurring/edit.py
@@ -41,6 +41,7 @@ def invoice_get_existing_data(invoice_obj: InvoiceRecurringProfile):
if invoice_obj.client_to:
stored_data["to_name"] = invoice_obj.client_to.name
stored_data["to_company"] = invoice_obj.client_to.company
+ stored_data["to_email"] = invoice_obj.client_to.email
stored_data["is_representative"] = invoice_obj.client_to.is_representative
# stored_data["to_address"] = invoice_obj.client_to.address
# stored_data["to_city"] = invoice_obj.client_to.city
@@ -49,6 +50,7 @@ def invoice_get_existing_data(invoice_obj: InvoiceRecurringProfile):
else:
stored_data["to_name"] = invoice_obj.client_name
stored_data["to_company"] = invoice_obj.client_company
+ stored_data["to_email"] = invoice_obj.client_email
stored_data["to_address"] = invoice_obj.client_address
stored_data["to_city"] = invoice_obj.client_city
stored_data["to_county"] = invoice_obj.client_county
diff --git a/backend/views/core/invoices/single/edit.py b/backend/views/core/invoices/single/edit.py
index 33b3ac959..b3bd891d3 100644
--- a/backend/views/core/invoices/single/edit.py
+++ b/backend/views/core/invoices/single/edit.py
@@ -34,6 +34,7 @@ def invoice_get_existing_data(invoice_obj):
if invoice_obj.client_to:
stored_data["to_name"] = invoice_obj.client_to.name
stored_data["to_company"] = invoice_obj.client_to.company
+ stored_data["to_email"] = invoice_obj.client_to.email
stored_data["is_representative"] = invoice_obj.client_to.is_representative
# stored_data["to_address"] = invoice_obj.client_to.address
# stored_data["to_city"] = invoice_obj.client_to.city
@@ -42,6 +43,7 @@ def invoice_get_existing_data(invoice_obj):
else:
stored_data["to_name"] = invoice_obj.client_name
stored_data["to_company"] = invoice_obj.client_company
+ stored_data["to_email"] = invoice_obj.client_email
stored_data["to_address"] = invoice_obj.client_address
stored_data["to_city"] = invoice_obj.client_city
stored_data["to_county"] = invoice_obj.client_county
@@ -116,6 +118,7 @@ def edit_invoice(request: HtmxHttpRequest, invoice_id):
{
"client_name": request.POST.get("to_name"),
"client_company": request.POST.get("to_company"),
+ "client_email": request.POST.get("to_email"),
"client_address": request.POST.get("to_address"),
"client_city": request.POST.get("to_city"),
"client_county": request.POST.get("to_county"),
diff --git a/backend/views/core/invoices/single/manage_access.py b/backend/views/core/invoices/single/manage_access.py
index c8062b792..c62000a71 100644
--- a/backend/views/core/invoices/single/manage_access.py
+++ b/backend/views/core/invoices/single/manage_access.py
@@ -4,57 +4,48 @@
from backend.decorators import web_require_scopes
from backend.models import Invoice, InvoiceURL, QuotaLimit
+from backend.service.invoices.single.get_invoice import get_invoice_by_actor
from backend.types.htmx import HtmxHttpRequest
+from backend.types.requests import WebRequest
@web_require_scopes("invoices:write", False, False, "invoices:single:dashboard")
-def manage_access(request: HtmxHttpRequest, invoice_id):
- try:
- invoice = Invoice.objects.prefetch_related("invoice_urls").get(id=invoice_id, user=request.user)
- except Invoice.DoesNotExist:
+def manage_access(request: WebRequest, invoice_id):
+ invoice_resp = get_invoice_by_actor(request.actor, invoice_id, ["invoice_urls"])
+ if invoice_resp.failed:
messages.error(request, "Invoice not found")
return redirect("invoices:single:dashboard")
- all_access_codes = invoice.invoice_urls.values_list("uuid", "created_on").order_by("-created_on")
+ all_access_codes = invoice_resp.response.invoice_urls.values_list("uuid", "created_on").order_by("-created_on")
return render(
request,
"pages/invoices/single/manage_access/manage_access.html",
- {"all_codes": all_access_codes, "invoice": invoice},
+ {"all_codes": all_access_codes, "invoice": invoice_resp.response},
)
@web_require_scopes("invoices:write", False, False, "invoices:single:dashboard")
-def create_code(request: HtmxHttpRequest, invoice_id):
+def create_code(request: WebRequest, invoice_id):
if not request.htmx:
return redirect("invoices:single:dashboard")
if request.method != "POST":
return HttpResponse("Invalid request", status=400)
- try:
- invoice = Invoice.objects.get(id=invoice_id, user=request.user)
- except Invoice.DoesNotExist:
- return HttpResponse("Invoice not found", status=400)
-
- limit = QuotaLimit.objects.get(slug="invoices-access_codes").get_quota_limit(user=request.user)
-
- current_amount = InvoiceURL.objects.filter(invoice_id=invoice_id).count()
-
- if current_amount >= limit:
- messages.error(request, f"You have reached the quota limit for this service 'access_codes'")
- return render(request, "partials/messages_list.html", {"autohide": False})
+ invoice_resp = get_invoice_by_actor(request.actor, invoice_id, ["invoice_urls"])
+ if invoice_resp.failed:
+ messages.error(request, "Invoice not found")
+ return redirect("invoices:single:dashboard")
- code = InvoiceURL.objects.create(invoice=invoice, created_by=request.user)
+ code = InvoiceURL.objects.create(invoice=invoice_resp.response, created_by=request.user)
messages.success(request, "Successfully created code")
- # QuotaUsage.create_str(request.user, "invoices-access_codes", invoice_id)
-
return render(
request,
"pages/invoices/single/manage_access/_table_row.html",
- {"code": code.uuid, "created_on": code.created_on, "added": True},
+ {"code": code.uuid, "created_on": code.created_on, "created_by": code.get_created_by, "added": True},
)
@@ -68,6 +59,10 @@ def delete_code(request: HtmxHttpRequest, code):
invoice = Invoice.objects.get(id=code_obj.invoice.id)
if not invoice.has_access(request.user):
raise Invoice.DoesNotExist
+
+ # url was created by system | user cannot delete
+ if not code_obj.created_by:
+ raise InvoiceURL.DoesNotExist
except (Invoice.DoesNotExist, InvoiceURL.DoesNotExist):
messages.error(request, "Invalid URL")
return render(request, "base/toasts.html")
diff --git a/backend/views/core/settings/view.py b/backend/views/core/settings/view.py
index 2807c1121..340bb763c 100644
--- a/backend/views/core/settings/view.py
+++ b/backend/views/core/settings/view.py
@@ -5,7 +5,15 @@
from django.shortcuts import render
from backend.service.defaults.get import get_account_defaults
-from backend.service.settings.view import validate_page, get_user_profile, get_api_keys
+from backend.service.settings.view import (
+ validate_page,
+ get_user_profile,
+ get_api_keys,
+ account_page_context,
+ api_keys_page_context,
+ account_defaults_context,
+ email_templates_context,
+)
from backend.types.requests import WebRequest
@@ -21,13 +29,13 @@ def view_settings_page_endpoint(request: WebRequest, page: str | None = None):
match page:
case "account":
- user_profile = get_user_profile(request)
- context.update({"currency_signs": user_profile.CURRENCIES, "currency": user_profile.currency})
+ account_page_context(request, context)
case "api_keys":
- api_keys = get_api_keys(request)
- context.update({"api_keys": api_keys})
+ api_keys_page_context(request, context)
case "account_defaults":
- context.update({"account_defaults": get_account_defaults(request.actor)})
+ account_defaults_context(request, context)
+ case "email_templates":
+ email_templates_context(request, context)
template = f"pages/settings/pages/{page or 'profile'}.html"
diff --git a/backend/webhooks/invoices/recurring.py b/backend/webhooks/invoices/recurring.py
index 4251e13ea..5a58550b3 100644
--- a/backend/webhooks/invoices/recurring.py
+++ b/backend/webhooks/invoices/recurring.py
@@ -8,7 +8,7 @@
from backend.decorators import feature_flag_check
from backend.models import InvoiceRecurringProfile, Invoice, DefaultValues, AuditLog
from backend.service.defaults.get import get_account_defaults
-from backend.service.invoices.recurring.generation.next_invoice import generate_next_invoice_service
+from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service
from backend.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key
import logging
@@ -49,7 +49,7 @@ def handle_recurring_invoice_webhook_endpoint(request: WebRequest):
DATE_TODAY = datetime.now().date()
- svc_resp = generate_next_invoice_service(invoice_recurring_profile=invoice_recurring_profile, issue_date=DATE_TODAY)
+ svc_resp = safe_generate_next_invoice_service(invoice_recurring_profile=invoice_recurring_profile, issue_date=DATE_TODAY)
if svc_resp.success:
logger.info("Successfully generated next invoice")
diff --git a/docs/user-guide/emails/templates/index.md b/docs/user-guide/emails/templates/index.md
new file mode 100644
index 000000000..95fad7055
--- /dev/null
+++ b/docs/user-guide/emails/templates/index.md
@@ -0,0 +1,42 @@
+# Email Templates
+
+### Common Variables
+
+| Variable | Usage |
+|------------------|-----------------------------------------------------------------------------------------|
+| $first_name | Displays the users first name |
+| $invoice_id | Displays the unique invoice ID |
+| $invoice_ref | Displays the invoice reference ID you may have attached |
+| $due_date | Will display the date that the invoice is due (e.g. 12th December 2024) |
+| $amount_due | Will display the balance due for the invoice |
+| $currency | Will display the currency TEXT used for the invoice (e.g. USD) |
+| $currency_symbol | Will display the currency SYMBOL used for the invoice (e.g. $) |
+| $product_list | Will display a bullet point list of all product (names no descriptions) |
+| $company_name | Will display the company (or user) name of the sender |
+| $invoice_link | Will provide a link that allows the user to view their invoice always up to date online |
+
+### Examples
+
+```
+Hi $first_name,
+
+The invoice $invoice_id has been created for you to pay, due on the $due_date. Please pay at your earliest convenience.
+
+Balance Due: $amount_due $currency
+
+Many thanks,
+$company_name
+```
+
+may display
+
+```
+Hi John,
+
+The invoice 0054 has been created for you to pay, due on the 13th of October. Please pay at your earliest convenience.
+
+Balance Due: 150 USD
+
+Many thanks,
+Strelix
+```
diff --git a/frontend/templates/base/topbar/+icon_dropdown.html b/frontend/templates/base/topbar/+icon_dropdown.html
index 0e3bc67f7..a39f5765a 100644
--- a/frontend/templates/base/topbar/+icon_dropdown.html
+++ b/frontend/templates/base/topbar/+icon_dropdown.html
@@ -5,7 +5,7 @@
- Account Settings
+ Settings
{{ to_name | default:"No Name" }}
{{ to_company | default:"No Company" }}
+{{ to_email | default:"No Email Associated" }}
{{ to_address | default:"No address" }}
{{ to_city | default:"No city" }}
{{ to_county | default:"No county" }}
@@ -43,6 +44,7 @@