Skip to content

Commit

Permalink
✨(backend) send email reminder of upcoming debit installment
Browse files Browse the repository at this point in the history
The new django command `send_mail_upcoming_debit` will
retrieve all 'pending' state installments on orders
payment schedules and send a reminder email with a certain
amount of days in advance (configured with
`JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS`) to the order's
owner notifying them that they will be debited on their
credit card.

Fix #864
  • Loading branch information
jonathanreveille authored and kernicPanel committed Sep 19, 2024
1 parent a0ffc58 commit 6f8cc2e
Show file tree
Hide file tree
Showing 10 changed files with 591 additions and 92 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ and this project adheres to

### Added

- Send an email reminder to the user when an installment
will be debited on his credit card on his order's payment schedule
- Send an email to the user when an installment debit has been
refused
- Send an email to the user when an installment is successfully
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Management command to send a reminder email to the order's owner on next installment to pay"""

import logging
from datetime import timedelta

from django.conf import settings
from django.core.management import BaseCommand
from django.utils import timezone

from joanie.core.models import Order
from joanie.core.tasks.payment_schedule import send_mail_reminder_installment_debit_task
from joanie.core.utils.payment_schedule import is_next_installment_to_debit

logger = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Command to send an email to the order's owner notifying them that an upcoming
installment debit from their payment schedule will be debited soon on their credit card.
"""

help = __doc__

def handle(self, *args, **options):
"""
Retrieve all upcoming pending payment schedules depending on the target due date and
send an email reminder to the order's owner who will be soon debited.
"""
logger.info(
"Starting processing order payment schedule for upcoming installments."
)
due_date = timezone.localdate() + timedelta(
days=settings.JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS
)

found_orders_count = 0
for order in Order.objects.find_pending_installments().iterator():
for installment in order.payment_schedule:
if is_next_installment_to_debit(
installment=installment, due_date=due_date
):
logger.info("Sending reminder mail for order %s.", order.id)
send_mail_reminder_installment_debit_task.delay(
order_id=order.id, installment_id=installment["id"]
)
found_orders_count += 1

logger.info(
"Found %s upcoming 'pending' installment to debit",
found_orders_count,
)
22 changes: 21 additions & 1 deletion src/backend/joanie/core/tasks/payment_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

from joanie.celery_app import app
from joanie.core.models import Order
from joanie.core.utils.payment_schedule import is_installment_to_debit
from joanie.core.utils.payment_schedule import (
is_installment_to_debit,
send_mail_reminder_for_installment_debit,
)
from joanie.payment import get_payment_backend

logger = getLogger(__name__)
Expand All @@ -30,3 +33,20 @@ def debit_pending_installment(order_id):
credit_card_token=order.credit_card.token,
installment=installment,
)


@app.task
def send_mail_reminder_installment_debit_task(order_id, installment_id):
"""
Task to send an email reminder to the order's owner about the next installment debit.
"""
order = Order.objects.get(id=order_id)
installment = next(
(
installment
for installment in order.payment_schedule
if installment["id"] == installment_id
),
None,
)
send_mail_reminder_for_installment_debit(order, installment)
43 changes: 43 additions & 0 deletions src/backend/joanie/core/utils/payment_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@

from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext as _
from django.utils.translation import override

from dateutil.relativedelta import relativedelta
from stockholm import Money, Number
from stockholm.exceptions import ConversionError

from joanie.core import enums
from joanie.core.exceptions import InvalidConversionError
from joanie.core.utils.emails import prepare_context_for_upcoming_installment, send
from joanie.payment import get_country_calendar

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -144,6 +147,18 @@ def is_installment_to_debit(installment):
)


def is_next_installment_to_debit(installment, due_date):
"""
Check if the installment is pending and also if its due date will be equal to the parameter
`due_date` passed.
"""

return (
installment["state"] == enums.PAYMENT_STATE_PENDING
and installment["due_date"] == due_date
)


def has_installments_to_debit(order):
"""
Check if the order has any pending installments with reached due date.
Expand Down Expand Up @@ -176,3 +191,31 @@ def convert_amount_str_to_money_object(amount_str: str):
raise InvalidConversionError(
f"Invalid format for amount: {exception} : '{amount_str}'."
) from exception


def send_mail_reminder_for_installment_debit(order, installment):
"""
Prepare the context variables for the mail reminder when the next installment debit
from the payment schedule will happen for the owner of the order.
"""
with override(order.owner.language):
product_title = order.product.safe_translation_getter(
"title", language_code=order.owner.language
)
currency = settings.DEFAULT_CURRENCY
days_until_debit = settings.JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS
installment_amount = Money(installment["amount"])
subject = _(
f"{settings.JOANIE_CATALOG_NAME} - {product_title} - "
f"An installment of {installment_amount} {currency} will be debited in "
f"{days_until_debit} days."
)

send(
subject=subject,
template_vars=prepare_context_for_upcoming_installment(
order, installment_amount, product_title, days_until_debit
),
template_name="installment_reminder",
to_user_email=order.owner.email,
)
31 changes: 3 additions & 28 deletions src/backend/joanie/payment/backends/base.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
"""Base Payment Backend"""

import smtplib
from logging import getLogger

from django.conf import settings
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import override
Expand Down Expand Up @@ -67,36 +64,14 @@ def _do_on_payment_success(cls, order, payment):
upcoming_installment=not upcoming_installment,
)

@classmethod
def _send_mail(cls, subject, template_vars, template_name, to_user_email):
"""Send mail with the current language of the user"""
try:
msg_html = render_to_string(
f"mail/html/{template_name}.html", template_vars
)
msg_plain = render_to_string(
f"mail/text/{template_name}.txt", template_vars
)
send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
[to_user_email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
# no exception raised as user can't sometimes change his mail,
logger.error("%s purchase order mail %s not send", to_user_email, exception)

@classmethod
def _send_mail_subscription_success(cls, order):
"""
Send mail with the current language of the user when an order subscription is
confirmed
"""
with override(order.owner.language):
cls._send_mail(
emails.send(
subject=_("Subscription confirmed!"),
template_vars={
"title": _("Subscription confirmed!"),
Expand Down Expand Up @@ -136,7 +111,7 @@ def _send_mail_payment_installment_success(
f"Order completed ! The last installment of {installment_amount} {currency} "
"has been debited"
)
cls._send_mail(
emails.send(
subject=f"{base_subject}{variable_subject_part}",
template_vars=emails.prepare_context_data(
order,
Expand Down Expand Up @@ -174,7 +149,7 @@ def _send_mail_refused_debit(cls, order, installment_id):
product_title = order.product.safe_translation_getter(
"title", language_code=order.owner.language
)
cls._send_mail(
emails.send(
subject=_(
f"{settings.JOANIE_CATALOG_NAME} - {product_title} - An installment debit "
f"has failed {installment_amount} {settings.DEFAULT_CURRENCY}"
Expand Down
133 changes: 131 additions & 2 deletions src/backend/joanie/tests/core/tasks/test_payment_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,38 @@

import json
from datetime import date, datetime
from decimal import Decimal as D
from logging import Logger
from unittest import mock
from zoneinfo import ZoneInfo

from django.core import mail
from django.core.management import call_command
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse

from rest_framework.test import APIRequestFactory
from stockholm import Money

from joanie.core.enums import (
ORDER_STATE_PENDING,
ORDER_STATE_PENDING_PAYMENT,
ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
PAYMENT_STATE_PAID,
PAYMENT_STATE_PENDING,
PAYMENT_STATE_REFUSED,
)
from joanie.core.factories import OrderFactory, UserAddressFactory, UserFactory
from joanie.core.tasks.payment_schedule import debit_pending_installment
from joanie.core.factories import (
OrderFactory,
OrderGeneratorFactory,
UserAddressFactory,
UserFactory,
)
from joanie.core.tasks.payment_schedule import (
debit_pending_installment,
send_mail_reminder_installment_debit_task,
)
from joanie.payment import get_payment_backend
from joanie.payment.backends.dummy import DummyPaymentBackend
from joanie.payment.factories import InvoiceFactory
Expand Down Expand Up @@ -320,3 +333,119 @@ def test_utils_payment_schedule_should_catch_up_late_payments_for_installments_s
},
],
)

@override_settings(
JOANIE_PAYMENT_SCHEDULE_LIMITS={
5: (30, 70),
},
JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS=2,
DEFAULT_CURRENCY="EUR",
)
def test_payment_scheduled_send_mail_reminder_installment_debit_task_full_cycle(
self,
):
"""
According to the value configured in the setting `JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS`,
that is 2 days for this test, the command should find the orders that must be treated
and calls the method responsible to send the reminder email to the owner orders.
"""
owner_1 = UserFactory(
first_name="John",
last_name="Doe",
email="john.doe@acme.org",
language="fr-fr",
)
UserAddressFactory(owner=owner_1)
owner_2 = UserFactory(
first_name="Sam", last_name="Doe", email="sam@fun-test.fr", language="fr-fr"
)
UserAddressFactory(owner=owner_2)
order_1 = OrderGeneratorFactory(
owner=owner_1,
state=ORDER_STATE_PENDING_PAYMENT,
product__price=D("5"),
product__title="Product 1",
)
order_1.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
order_1.payment_schedule[0]["due_date"] = date(2024, 1, 17)
order_1.payment_schedule[1]["id"] = "1932fbc5-d971-48aa-8fee-6d637c3154a5"
order_1.payment_schedule[1]["due_date"] = date(2024, 2, 17)
order_1.payment_schedule[1]["state"] = PAYMENT_STATE_PENDING
order_1.save()
order_2 = OrderGeneratorFactory(
owner=owner_2,
state=ORDER_STATE_PENDING_PAYMENT,
product__price=D("5"),
product__title="Product 2",
)
order_2.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
order_2.payment_schedule[1]["id"] = "a1cf9f39-594f-4528-a657-a0b9018b90ad"
order_2.payment_schedule[1]["due_date"] = date(2024, 2, 17)
order_2.save()
# This order should be ignored by the django command `send_mail_upcoming_debit`
order_3 = OrderGeneratorFactory(
state=ORDER_STATE_PENDING_PAYMENT,
product__price=D("5"),
product__title="Product 2",
)
order_3.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
order_3.payment_schedule[1]["due_date"] = date(2024, 2, 18)
order_3.save()

# Orders that should be found with their installment that will be debited soon
expected_calls = [
mock.call.delay(
order_id=order_2.id,
installment_id="a1cf9f39-594f-4528-a657-a0b9018b90ad",
),
mock.call.delay(
order_id=order_1.id,
installment_id="1932fbc5-d971-48aa-8fee-6d637c3154a5",
),
]

with (
mock.patch(
"django.utils.timezone.localdate", return_value=date(2024, 2, 15)
),
mock.patch(
"joanie.core.tasks.payment_schedule.send_mail_reminder_installment_debit_task"
) as mock_send_mail_reminder_installment_debit_task,
):
call_command("send_mail_upcoming_debit")

mock_send_mail_reminder_installment_debit_task.assert_has_calls(
expected_calls, any_order=False
)

# Trigger now the task `send_mail_reminder_installment_debit_task` for order_1
send_mail_reminder_installment_debit_task.run(
order_id=order_1.id, installment_id=order_1.payment_schedule[1]["id"]
)

# Check if mail was sent to owner_1 about next upcoming debit
self.assertEqual(mail.outbox[0].to[0], "john.doe@acme.org")
self.assertIn("will be debited in 2 days.", mail.outbox[0].subject)
email_content_1 = " ".join(mail.outbox[0].body.split())
fullname_1 = order_1.owner.get_full_name()
self.assertIn(f"Hello {fullname_1}", email_content_1)
self.assertIn("installment will be withdrawn on 2 days", email_content_1)
self.assertIn("We will try to debit an amount of", email_content_1)
self.assertIn("3,5", email_content_1)
self.assertIn("Product 1", email_content_1)

# Trigger now the task `send_mail_reminder_installment_debit_task` for order_2
send_mail_reminder_installment_debit_task.run(
order_id=order_2.id, installment_id=order_2.payment_schedule[1]["id"]
)

# Check if mail was sent to owner_2 about next upcoming debit
self.assertEqual(mail.outbox[1].to[0], "sam@fun-test.fr")
self.assertIn("will be debited in 2 days.", mail.outbox[1].subject)
fullname_2 = order_2.owner.get_full_name()
email_content_2 = " ".join(mail.outbox[1].body.split())
self.assertIn(f"Hello {fullname_2}", email_content_2)
self.assertIn("installment will be withdrawn on 2 days", email_content_2)
self.assertIn("We will try to debit an amount of", email_content_2)
self.assertIn("1,5", email_content_2)
self.assertIn("Product 2", email_content_2)
Loading

0 comments on commit 6f8cc2e

Please sign in to comment.