Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/invoice sending #526

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions backend/core/api/base/modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,15 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value
else:
context[context_type] = context_value

if modal_name == "send_single_email" or modal_name == "send_bulk_email":
if modal_name == "send_single_email" or modal_name == "send_bulk_email" or modal_name == "send_email_from_invoice":
if not get_feature_status("areUserEmailsAllowed"):
messages.error(request, "Emails are disabled")
return render(request, "base/toast.html")
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)
context["email_list"] = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False).values_list("email", flat=True)
context["invoice_url"] = context_value

if context_type == "invoice_code_send":
invoice_url: InvoiceURL | None = InvoiceURL.objects.filter(uuid=context_value).prefetch_related("invoice").first()
Expand All @@ -157,8 +158,7 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value
if value is not None
]

context["email_list"] = list(context["email_list"]) + context["selected_clients"]

context["email_list"] = list(filter(lambda i: i is not "", 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
Expand Down
180 changes: 178 additions & 2 deletions backend/core/api/emails/send.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@
from django.http import HttpResponse
from django.shortcuts import render
from django.views.decorators.http import require_POST
from mypy_boto3_sesv2.type_defs import BulkEmailEntryResultTypeDef
from mypy_boto3_sesv2.type_defs import BulkEmailEntryResultTypeDef, SendEmailResponseTypeDef

from backend.core.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.finance.models import Invoice, InvoiceURL
from backend.models import Client
from backend.models import EmailSendStatus
from backend.models import QuotaLimit
Expand Down Expand Up @@ -65,6 +66,181 @@ def send_bulk_email_view(request: WebRequest) -> HttpResponse:
return _send_bulk_email_view(request)


@require_POST
@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_invoice_email_view(request: WebRequest, uuid) -> 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)
# if not isinstance(check_usage, bool):
# return check_usage
return _send_invoice_email_view(request, uuid)


def _send_invoice_email_view(request: WebRequest, uuid) -> HttpResponse:
emails: list[str] = request.POST.getlist("emails")
subject: str = request.POST.get("subject", "")
message: str = request.POST.get("content", "")
cc_emails = request.POST.get("cc_emails", "").split(",") if request.POST.get("cc_emails") else []
bcc_emails = request.POST.get("bcc_emails", "").split(",") if request.POST.get("bcc_emails") else []
invoiceurl_uuid = uuid
invoice_id = InvoiceURL.objects.filter(uuid=invoiceurl_uuid).values_list("invoice_id", flat=True).first()

if invoice_id is None:
messages.error(request, "Invalid Invoice")
return render(request, "base/toast.html")

invoice = Invoice.objects.filter(id=invoice_id).first()

item_names = invoice.items.values_list("name", flat=True)
item_names_list = list(item_names)

if request.user.logged_in_as_team:
clients = Client.objects.filter(organization=request.user.logged_in_as_team, email__in=emails)
else:
clients = Client.objects.filter(user=request.user, email__in=emails)

validated_bulk = validate_bulk_inputs(request=request, emails=emails, clients=clients, message=message, subject=subject)

if validated_bulk:
messages.error(request, validated_bulk)
return render(request, "base/toast.html")

message += email_footer()
message_single_line_html = message.replace("\r\n", "<br>").replace("\n", "<br>")

email_list: list[BulkEmailEmailItem] = []

for email in emails:
client = clients.filter(email=email).first()

email_data = {
"first_name": invoice.client_name if invoice.client_name else "User",
"invoice_id": invoice.id,
"invoice_ref": invoice.reference or invoice.id,
"due_date": invoice.date_due.strftime("%A, %B %d, %Y"),
"amount_due": invoice.get_total_price(),
"currency": invoice.currency,
"currency_symbol": invoice.get_currency_symbol(),
"product_list": "\n".join(f"- {item_name}" for item_name in item_names_list),
"company_name": invoice.self_company or invoice.self_name or "MyFinances Customer",
"invoice_link": get_var("SITE_URL") + "/invoice/" + invoiceurl_uuid,
}

email_list.append(
BulkEmailEmailItem(
destination=email,
cc=cc_emails,
bcc=bcc_emails,
template_data={
"users_name": client.name.split()[0] if client else "User",
"content_text": Template(message).safe_substitute(email_data),
"content_html": Template(message_single_line_html).safe_substitute(email_data),
},
)
)

if get_var("DEBUG", "").lower() == "false":
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_email(
destination=emails,
subject=subject,
content={
"template_name": "user_send_client_email",
"template_data": {
"subject": subject,
"sender_name": request.user.first_name or request.user.email,
"sender_id": request.user.id,
"content_text": Template(message).safe_substitute(email_data),
"content_html": Template(message_single_line_html).safe_substitute(email_data),
},
},
from_address=request.user.email,
cc=cc_emails,
bcc=bcc_emails,
)

if EMAIL_SENT.failed:
messages.error(request, EMAIL_SENT.error)
return render(request, "base/toast.html")

EMAIL_RESPONSES: Iterator[tuple[str, SendEmailResponseTypeDef]] = (
(
email_item.destination,
send_email(
destination=email_item.destination,
subject=subject,
content=email_item.template_data["content_text"], # type: ignore[index]
from_address=request.user.email,
cc=email_item.cc,
bcc=email_item.bcc,
).response,
)
for email_item in email_list
)

if request.user.logged_in_as_team:
SEND_STATUS_OBJECTS: list[EmailSendStatus] = EmailSendStatus.objects.bulk_create(
[
EmailSendStatus(
organization=request.user.logged_in_as_team,
sent_by=request.user,
recipient=email_address,
aws_message_id=response.get("MessageId"),
status="pending",
)
for email_address, response in EMAIL_RESPONSES
if response
]
)
else:
SEND_STATUS_OBJECTS = EmailSendStatus.objects.bulk_create(
[
EmailSendStatus(
user=request.user,
sent_by=request.user,
recipient=email_address,
aws_message_id=response.get("MessageId"),
status="pending",
)
for email_address, response in EMAIL_RESPONSES
if response
]
)

messages.success(request, f"Successfully emailed {len(email_list)} people.")

try:
quota_limits = QuotaLimit.objects.filter(slug__in=["emails-single-count", "emails-bulk-count"])

QuotaUsage.objects.bulk_create(
[
QuotaUsage(user=request.user, quota_limit=quota_limits.get(slug="emails-single-count"), extra_data=status.id)
for status in SEND_STATUS_OBJECTS
]
+ [QuotaUsage(user=request.user, quota_limit=quota_limits.get(slug="emails-bulk-count"))]
)
except QuotaLimit.DoesNotExist:
...

return render(request, "base/toast.html")


def _send_bulk_email_view(request: WebRequest) -> HttpResponse:
emails: list[str] = request.POST.getlist("emails")
subject: str = request.POST.get("subject", "")
Expand Down Expand Up @@ -120,7 +296,7 @@ def _send_bulk_email_view(request: WebRequest) -> HttpResponse:
"sender_id": request.user.id,
"subject": subject,
},
}
},
)
messages.success(request, f"Successfully emailed {len(email_list)} people.")
return render(request, "base/toast.html")
Expand Down
5 changes: 5 additions & 0 deletions backend/core/api/emails/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
send.send_bulk_email_view,
name="send bulk",
),
path(
"send/invoice/<str:uuid>",
send.send_invoice_email_view,
name="send invoice",
),
path("fetch/", fetch.fetch_all_emails, name="fetch"),
path("get_status/<str:status_id>/", status.get_status_view, name="get_status"),
path("refresh_statuses/", status.refresh_all_statuses_view, name="refresh statuses"),
Expand Down
59 changes: 57 additions & 2 deletions backend/core/data/default_email_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,63 @@ def email_footer() -> str:
"""
Note: This is an automated email sent by MyFinances on behalf of '$company_name'.

If you believe this email is spam or fraudulent, please do not pay the invoice and report it to us immediately at report@myfinances.cloud.
Once reported, we will open a case for investigation. In some cases, eligible reports may qualify for a reward, determined on a case-by-case basis.
If you suspect this email is spam or fraudulent, please let us know immediately to report@myfinances.cloud and avoid making payments.
"""
).strip()
)


def invoice_state_pending_template() -> str:
return dedent(
"""
Hi $first_name,

Your invoice #$invoice_id is now available and is due by $due_date. Please make your payment at your earliest convenience.

Balance Due: $currency_symbol$amount_due $currency
View or Pay Online: $invoice_link
If you are paying by standing order, no further action is required. Should you have any questions or concerns, feel free to reach out to us.

Thank you for your prompt attention to this matter.

Best regards,
$company_name
"""
).strip()


def invoice_state_paid_template() -> str:
return dedent(
"""
Hi $first_name,

The invoice #$invoice_id has just been paid.

If you have any questions or concerns, please feel free to contact us.

Many thanks,
$company_name
"""
).strip()


def invoice_state_overdue_template() -> str:
return dedent(
"""
Hi $first_name,

We wanted to remind you that invoice #$invoice_id is now overdue. Please arrange payment as soon as possible to ensure there’s no interruption in your service. If you’ve already made the payment, kindly disregard this message—our apologies for any confusion.

Here are the details for your convenience:

Balance Due: $currency_symbol$amount_due $currency
Due Date: $due_date

If you have any questions or concerns, we’re happy to help. Please don’t hesitate to reach out.

Thank you for your prompt attention to this matter.

Warm regards,
$company_name
"""
).strip()
31 changes: 31 additions & 0 deletions backend/core/signals/signals.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from __future__ import annotations

from string import Template

from django.core.cache import cache
from django.core.cache.backends.redis import RedisCacheClient

from backend.core.data.default_email_templates import email_footer, invoice_state_overdue_template
from backend.finance.models import Invoice

cache: RedisCacheClient = cache
from django.core.files.storage import default_storage
from django.db.models.signals import pre_save, post_delete, post_save, pre_delete
Expand Down Expand Up @@ -96,3 +101,29 @@ def send_welcome_email(sender, instance: User, created, **kwargs):
email = send_email(destination=instance.email, subject="Welcome to MyFinances", content=email_message)

# User.send_welcome_email(instance)


@receiver(post_save, sender=Invoice)
def send_overdue_invoice_email(sender, instance: Invoice, **kwargs):
if instance.dynamic_status == "overdue":
client_email = instance.client_to.email if instance.client_to else instance.client_email
if client_email:
message: str = invoice_state_overdue_template() + email_footer()

user_data = {
"first_name": instance.client_to.name.split(" ")[0] if instance.client_to else instance.client_name,
"invoice_id": instance.id,
"due_date": instance.date_due.strftime("%A, %B %d, %Y"),
"amount_due": instance.get_total_price(),
"currency": instance.currency,
"currency_symbol": instance.get_currency_symbol(),
"company_name": instance.self_company or instance.self_name or "MyFinances Customer",
}

output: str = Template(message).safe_substitute(user_data)

send_email(
destination=client_email,
subject=f"Invoice #{instance.id} from {instance.self_company or instance.self_name} is overdue",
content=output,
)
2 changes: 2 additions & 0 deletions backend/core/types/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class SingleEmailInput:
ConfigurationSetName: str | None = None
from_address: str | None = None
from_address_name_prefix: str | None = None
cc: list[str] = field(default_factory=list)
bcc: list[str] = field(default_factory=list)


@dataclass
Expand Down
Loading