Skip to content

Commit

Permalink
🎨 Send email on successful payment w/ payment-method (ITISFoundation#…
Browse files Browse the repository at this point in the history
  • Loading branch information
pcrespov authored and jsaq007 committed Feb 27, 2024
1 parent 401a646 commit 9d7f5f6
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ...db.payments_methods_repo import PaymentsMethodsRepo
from ...db.payments_transactions_repo import PaymentsTransactionsRepo
from ...services import payments, payments_methods
from ...services.notifier import NotifierService
from ...services.payments_gateway import PaymentsGatewayApi
from ...services.resource_usage_tracker import ResourceUsageTrackerApi

Expand Down Expand Up @@ -176,6 +177,7 @@ async def pay_with_payment_method( # noqa: PLR0913 # pylint: disable=too-many-a
rut=ResourceUsageTrackerApi.get_from_app_state(app),
repo_transactions=PaymentsTransactionsRepo(db_engine=app.state.engine),
repo_methods=PaymentsMethodsRepo(db_engine=app.state.engine),
notifier=NotifierService.get_from_app_state(app),
payment_method_id=payment_method_id,
amount_dollars=amount_dollars,
target_credits=target_credits,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from ..core.settings import ApplicationSettings
from .auto_recharge import get_wallet_auto_recharge
from .notifier import NotifierService
from .payments import pay_with_payment_method
from .payments_gateway import PaymentsGatewayApi
from .rabbitmq import get_rabbitmq_rpc_client
Expand Down Expand Up @@ -146,15 +147,13 @@ async def _perform_auto_recharge(
)
credit_result = parse_obj_as(CreditResultGet, result)

payments_gateway = PaymentsGatewayApi.get_from_app_state(app)
payments_transactions_repo = PaymentsTransactionsRepo(db_engine=app.state.engine)
rut_api = ResourceUsageTrackerApi.get_from_app_state(app)

await pay_with_payment_method(
gateway=payments_gateway,
rut=rut_api,
repo_transactions=payments_transactions_repo,
gateway=PaymentsGatewayApi.get_from_app_state(app),
rut=ResourceUsageTrackerApi.get_from_app_state(app),
repo_transactions=PaymentsTransactionsRepo(db_engine=app.state.engine),
repo_methods=PaymentsMethodsRepo(db_engine=app.state.engine),
notifier=NotifierService.get_from_app_state(app),
#
payment_method_id=cast(PaymentMethodID, wallet_auto_recharge.payment_method_id),
amount_dollars=wallet_auto_recharge.top_up_amount_in_usd,
target_credits=credit_result.credit_amount,
Expand Down
39 changes: 24 additions & 15 deletions services/payments/src/simcore_service_payments/services/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
)
from models_library.users import UserID
from servicelib.fastapi.app_state import SingletonInAppStateMixin
from servicelib.utils import logged_gather
from servicelib.utils import fire_and_forget_task

from ..core.settings import ApplicationSettings
from ..db.payment_users_repo import PaymentsUsersRepo
Expand All @@ -25,23 +25,34 @@ class NotifierService(SingletonInAppStateMixin):

def __init__(self, *providers):
self.providers: list[NotificationProvider] = list(providers)
self._background_tasks = set()

def _run_in_background(self, coro, suffix):
fire_and_forget_task(
coro,
task_suffix_name=suffix,
fire_and_forget_tasks_collection=self._background_tasks,
)

async def notify_payment_completed(
self,
user_id: UserID,
payment: PaymentTransaction,
*,
exclude: set | None = None,
):
if payment.completed_at is None:
msg = "Cannot notify incomplete payment"
raise ValueError(msg)

await logged_gather(
*(
provider.notify_payment_completed(user_id=user_id, payment=payment)
for provider in self.providers
),
reraise=False,
)
exclude = exclude or set()
providers = [p for p in self.providers if p.get_name() not in exclude]

for provider in providers:
self._run_in_background(
provider.notify_payment_completed(user_id=user_id, payment=payment),
f"{provider.get_name()}_u_{user_id}_p_{payment.payment_id}",
)

async def notify_payment_method_acked(
self,
Expand All @@ -52,15 +63,13 @@ async def notify_payment_method_acked(
msg = "Cannot notify unAcked payment-method"
raise ValueError(msg)

await logged_gather(
*(
for provider in self.providers:
self._run_in_background(
provider.notify_payment_method_acked(
user_id=user_id, payment_method=payment_method
)
for provider in self.providers
),
reraise=False,
)
),
f"{provider.get_name()}_u_{user_id}_pm_{payment_method.payment_method_id}",
)


def setup_notifier(app: FastAPI):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ async def notify_payment_method_acked(
payment_method: PaymentMethodTransaction,
):
...

@classmethod
def get_name(cls):
return cls.__name__
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,12 @@ def __init__(self, settings: SMTPSettings, users_repo: PaymentsUsersRepo):
autoescape=select_autoescape(["html", "xml"]),
)

async def _create_message(
async def _create_successful_payments_message(
self, user_id: UserID, payment: PaymentTransaction
) -> EmailMessage:

data = await self._users_repo.get_notification_data(user_id, payment.payment_id)

# email for successful payment
msg: EmailMessage = await _create_user_email(
self._jinja_env,
user=_UserData(
Expand Down Expand Up @@ -249,10 +249,17 @@ async def notify_payment_completed(
user_id: UserID,
payment: PaymentTransaction,
):
msg = await self._create_message(user_id, payment)

async with _create_email_session(self._settings) as smtp:
await smtp.send_message(msg)
# NOTE: we only have an email for successful payments
if payment.state == "SUCCESS":
msg = await self._create_successful_payments_message(user_id, payment)
async with _create_email_session(self._settings) as smtp:
await smtp.send_message(msg)
else:
_logger.debug(
"No email sent when %s did a non-SUCCESS %s",
f"{user_id=}",
f"{payment=}",
)

async def notify_payment_method_acked(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from ..models.schemas.acknowledgements import AckPayment, AckPaymentWithPaymentMethod
from ..services.resource_usage_tracker import ResourceUsageTrackerApi
from .notifier import NotifierService
from .notifier_ws import WebSocketProvider
from .payments_gateway import PaymentsGatewayApi

_logger = logging.getLogger()
Expand Down Expand Up @@ -148,7 +149,8 @@ async def acknowledge_one_time_payment(
async def on_payment_completed(
transaction: PaymentsTransactionsDB,
rut_api: ResourceUsageTrackerApi,
notifier: NotifierService | None,
notifier: NotifierService,
exclude: set | None = None,
):
assert transaction.completed_at is not None # nosec
assert transaction.initiated_at < transaction.completed_at # nosec
Expand Down Expand Up @@ -181,17 +183,19 @@ async def on_payment_completed(
f"{credit_transaction_id=}",
)

if notifier:
await notifier.notify_payment_completed(
user_id=transaction.user_id, payment=to_payments_api_model(transaction)
)
await notifier.notify_payment_completed(
user_id=transaction.user_id,
payment=to_payments_api_model(transaction),
exclude=exclude,
)


async def pay_with_payment_method( # noqa: PLR0913
gateway: PaymentsGatewayApi,
rut: ResourceUsageTrackerApi,
repo_transactions: PaymentsTransactionsRepo,
repo_methods: PaymentsMethodsRepo,
notifier: NotifierService,
*,
payment_method_id: PaymentMethodID,
amount_dollars: Decimal,
Expand Down Expand Up @@ -255,7 +259,9 @@ async def pay_with_payment_method( # noqa: PLR0913
)

# NOTE: notifications here are done as background-task after responding `POST /wallets/{wallet_id}/payments-methods/{payment_method_id}:pay`
await on_payment_completed(transaction, rut, notifier=None)
await on_payment_completed(
transaction, rut, notifier=notifier, exclude={WebSocketProvider.get_name()}
)

return to_payments_api_model(transaction)

Expand Down
48 changes: 47 additions & 1 deletion services/payments/tests/unit/test_services_payments.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# pylint: disable=protected-access
# pylint: disable=redefined-outer-name
# pylint: disable=too-many-arguments
# pylint: disable=unused-argument
# pylint: disable=unused-variable
# pylint: disable=too-many-arguments


import asyncio
from collections.abc import Awaitable, Callable
from unittest.mock import MagicMock

import pytest
from fastapi import FastAPI
Expand All @@ -23,6 +26,9 @@
)
from simcore_service_payments.models.db import PaymentsMethodsDB
from simcore_service_payments.services import payments
from simcore_service_payments.services.notifier import NotifierService
from simcore_service_payments.services.notifier_email import EmailProvider
from simcore_service_payments.services.notifier_ws import WebSocketProvider
from simcore_service_payments.services.payments_gateway import PaymentsGatewayApi
from simcore_service_payments.services.resource_usage_tracker import (
ResourceUsageTrackerApi,
Expand Down Expand Up @@ -58,6 +64,20 @@ def app_environment(
)


@pytest.fixture
def mock_email_provider(mocker: MockerFixture) -> MagicMock:
mock = mocker.MagicMock(EmailProvider)
mock.get_name.return_value = EmailProvider.get_name()
return mock


@pytest.fixture
def mock_ws_provider(mocker: MockerFixture) -> MagicMock:
mock = mocker.MagicMock(WebSocketProvider)
mock.get_name.return_value = WebSocketProvider.get_name()
return mock


async def test_fails_to_pay_with_payment_method_without_funds(
app: FastAPI,
create_fake_payment_method_in_db: Callable[
Expand All @@ -72,6 +92,8 @@ async def test_fails_to_pay_with_payment_method_without_funds(
user_email: EmailStr,
payments_clean_db: None,
mocker: MockerFixture,
mock_email_provider: MagicMock,
mock_ws_provider: MagicMock,
):
if mock_payments_gateway_service_or_none is None:
pytest.skip(
Expand All @@ -87,11 +109,16 @@ async def test_fails_to_pay_with_payment_method_without_funds(
rut = ResourceUsageTrackerApi.get_from_app_state(app)
rut_create_credit_transaction = mocker.spy(rut, "create_credit_transaction")

# Mocker providers
notifier = NotifierService(mock_email_provider, mock_ws_provider)

payment = await payments.pay_with_payment_method(
gateway=PaymentsGatewayApi.get_from_app_state(app),
rut=rut,
repo_transactions=PaymentsTransactionsRepo(db_engine=app.state.engine),
repo_methods=PaymentsMethodsRepo(db_engine=app.state.engine),
notifier=notifier,
#
payment_method_id=payment_method_without_funds.payment_method_id,
amount_dollars=100,
target_credits=100,
Expand All @@ -104,9 +131,28 @@ async def test_fails_to_pay_with_payment_method_without_funds(
comment="test_failure_in_pay_with_payment_method",
)

# should not add credits
assert not rut_create_credit_transaction.called

# check resulting payment
assert payment.completed_at is not None
assert payment.created_at < payment.completed_at
assert payment.state == "FAILED"
assert payment.state_message, "expected reason of failure"

# check notifications triggered as background tasks
await asyncio.sleep(0.1)
assert len(notifier._background_tasks) == 0 # noqa: SLF001

assert mock_email_provider.notify_payment_completed.called
assert (
mock_email_provider.notify_payment_completed.call_args.kwargs["user_id"]
== user_id
)
assert (
mock_email_provider.notify_payment_completed.call_args.kwargs["payment"]
== payment
)

# Websockets notification should be in the exclude list
assert not mock_ws_provider.notify_payment_completed.called

0 comments on commit 9d7f5f6

Please sign in to comment.