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 committed Aug 26, 2024
1 parent d5e4f4a commit 4df154f
Show file tree
Hide file tree
Showing 9 changed files with 692 additions and 15 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,56 @@
"""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().date()
+ timedelta(days=settings.JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS)
).isoformat()

found_orders_count = 0
sent_email_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"]
)
sent_email_count += 1
found_orders_count += 1

logger.info(
"Found %s upcoming 'pending' installment and %s mails sent",
found_orders_count,
sent_email_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 his 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)
32 changes: 25 additions & 7 deletions src/backend/joanie/core/utils/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

from stockholm import Money

from joanie.core.enums import PAYMENT_STATE_PAID, PAYMENT_STATE_REFUSED
from joanie.core.enums import (
PAYMENT_STATE_PAID,
PAYMENT_STATE_PENDING,
PAYMENT_STATE_REFUSED,
)


def prepare_context_data(
Expand All @@ -30,13 +34,9 @@ def prepare_context_data(
"url": settings.JOANIE_CATALOG_BASE_URL,
},
"targeted_installment_index": (
order.get_index_of_installment(
state=PAYMENT_STATE_REFUSED, find_first=False
)
order.get_index_of_installment(state=PAYMENT_STATE_REFUSED)
if payment_refused
else order.get_index_of_installment(
state=PAYMENT_STATE_PAID, find_first=False
)
else order.get_index_of_installment(state=PAYMENT_STATE_PAID)
),
}

Expand All @@ -48,3 +48,21 @@ def prepare_context_data(
context_data.update(variable_context_part)

return context_data


def prepare_context_for_upcoming_installment(
order, installment_amount, product_title, days_until_debit
):
"""
Prepare the context variables for the email when an upcoming installment payment
will be soon debited for a user.
"""
context_data = prepare_context_data(
order, installment_amount, product_title, payment_refused=False
)
context_data["targeted_installment_index"] = order.get_index_of_installment(
state=PAYMENT_STATE_PENDING, find_first=True
)
context_data["days_until_debit"] = days_until_debit

return context_data
47 changes: 45 additions & 2 deletions src/backend/joanie/core/utils/payment_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +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 joanie.core import enums
from joanie.core.utils.emails import prepare_context_for_upcoming_installment
from joanie.payment import get_country_calendar
from joanie.payment.backends.base import BasePaymentBackend

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -134,11 +138,21 @@ def is_installment_to_debit(installment):
"""
Check if the installment is pending and has reached due date.
"""
due_date = timezone.localdate().isoformat()

return (
installment["state"] == enums.PAYMENT_STATE_PENDING
and installment["due_date"] <= due_date
and installment["due_date"] <= timezone.localdate().isoformat()
)


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
)


Expand All @@ -150,3 +164,32 @@ def has_installments_to_debit(order):
return any(
is_installment_to_debit(installment) for installment in order.payment_schedule
)


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."
)
# pylint: disable=protected-access
# ruff : noqa : SLF001
BasePaymentBackend._send_mail(
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,
)
148 changes: 145 additions & 3 deletions src/backend/joanie/tests/core/tasks/test_payment_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,38 @@
"""

import json
from datetime import datetime
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 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,
ProductFactory,
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 @@ -318,3 +331,132 @@ def test_utils_payment_schedule_should_catch_up_late_payments_for_installments_s
},
],
)

@override_settings(
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 = UserFactory(
first_name="John",
last_name="Doe",
email="john.doe@acme.org",
language="fr-fr",
)
UserAddressFactory(owner=owner)
owner_2 = UserFactory(
first_name="Sam", last_name="Doe", email="sam@fun-test.fr", language="fr-fr"
)
order_1 = OrderFactory(
owner=owner,
product=ProductFactory(price=D("199.00"), title="Product 1"),
state=ORDER_STATE_PENDING_PAYMENT,
payment_schedule=[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
"amount": "60.00",
"due_date": date(2024, 1, 17).isoformat(),
"state": PAYMENT_STATE_PAID,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
"amount": "139.99",
"due_date": date(2024, 2, 17).isoformat(),
"state": PAYMENT_STATE_PENDING,
},
],
)
order_2 = OrderFactory(
owner=owner_2,
product=ProductFactory(price=D("149.99"), title="Product 2"),
state=ORDER_STATE_PENDING_PAYMENT,
payment_schedule=[
{
"id": "a1cf9f39-594f-4528-a657-a0b9018b90ad",
"amount": "149.99",
"due_date": date(2024, 2, 17).isoformat(),
"state": PAYMENT_STATE_PENDING,
},
],
)
# This order should be ignored by the django command `send_mail_upcoming_debit`
OrderFactory(
product=ProductFactory(price=D("199.99"), title="Product 3"),
state=ORDER_STATE_PENDING_PAYMENT,
payment_schedule=[
{
"amount": "60.00",
"due_date": date(2024, 1, 18).isoformat(),
"state": PAYMENT_STATE_PAID,
},
{
"amount": "139.99",
"due_date": date(2024, 2, 18).isoformat(),
"state": PAYMENT_STATE_PENDING,
},
],
)

# 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",
),
]

mocked_now = datetime(2024, 2, 15, 0, 0, tzinfo=ZoneInfo("UTC"))
with (
mock.patch("django.utils.timezone.localdate", return_value=mocked_now),
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("60,00", 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[0]["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("149,99", email_content_2)
self.assertIn("Product 2", email_content_2)
Loading

0 comments on commit 4df154f

Please sign in to comment.