From 4e833ff7a0234036260407ffa8a5c42496c5df20 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 19 Oct 2023 10:56:30 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Connects=20webserver=20and=20paymen?= =?UTF-8?q?ts=20service=20(#4886)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_schemas_payments/__init__.py | 7 + .../models_library/rabbitmq_basic_types.py | 21 +++ .../src/servicelib/rabbitmq/__init__.py | 4 +- .../src/servicelib/rabbitmq/_client_rpc.py | 3 +- .../src/servicelib/rabbitmq/_constants.py | 1 - .../src/servicelib/rabbitmq/_models.py | 21 +-- .../api/rpc/routes.py | 15 +- .../services/rabbitmq.py | 6 +- .../payments/_autorecharge_api.py | 2 +- .../payments/_client.py | 135 ------------------ .../payments/_methods_api.py | 11 +- .../payments/{_api.py => _onetime_api.py} | 113 +++++++-------- .../payments/{_db.py => _onetime_db.py} | 42 +----- .../payments/_rpc.py | 78 ++++++++++ .../payments/_tasks.py | 12 +- .../simcore_service_webserver/payments/api.py | 16 +-- .../payments/plugin.py | 6 +- .../wallets/_payments_handlers.py | 8 +- .../with_dbs/03/wallets/payments/conftest.py | 11 +- .../03/wallets/payments/test_payments.py | 113 ++++++++++++--- .../wallets/payments/test_payments_methods.py | 6 +- 21 files changed, 303 insertions(+), 328 deletions(-) create mode 100644 packages/models-library/src/models_library/rabbitmq_basic_types.py delete mode 100644 services/web/server/src/simcore_service_webserver/payments/_client.py rename services/web/server/src/simcore_service_webserver/payments/{_api.py => _onetime_api.py} (78%) rename services/web/server/src/simcore_service_webserver/payments/{_db.py => _onetime_db.py} (75%) create mode 100644 services/web/server/src/simcore_service_webserver/payments/_rpc.py diff --git a/packages/models-library/src/models_library/api_schemas_payments/__init__.py b/packages/models-library/src/models_library/api_schemas_payments/__init__.py index e69de29bb2d..30d68367ded 100644 --- a/packages/models-library/src/models_library/api_schemas_payments/__init__.py +++ b/packages/models-library/src/models_library/api_schemas_payments/__init__.py @@ -0,0 +1,7 @@ +from typing import Final + +from pydantic import parse_obj_as + +from ..rabbitmq_basic_types import RPCNamespace + +PAYMENTS_RPC_NAMESPACE: Final[RPCNamespace] = parse_obj_as(RPCNamespace, "payments") diff --git a/packages/models-library/src/models_library/rabbitmq_basic_types.py b/packages/models-library/src/models_library/rabbitmq_basic_types.py new file mode 100644 index 00000000000..3d14a6459c5 --- /dev/null +++ b/packages/models-library/src/models_library/rabbitmq_basic_types.py @@ -0,0 +1,21 @@ +import re +from typing import Final + +from pydantic import ConstrainedStr, parse_obj_as + +REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS: Final[str] = r"^[\w\-\.]*$" + + +class RPCNamespace(ConstrainedStr): + min_length: int = 1 + max_length: int = 252 + regex: re.Pattern[str] | None = re.compile(REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS) + + @classmethod + def from_entries(cls, entries: dict[str, str]) -> "RPCNamespace": + """ + Given a list of entries creates a namespace to be used in declaring the rabbitmq queue. + Keeping this to a predefined length + """ + composed_string = "-".join(f"{k}_{v}" for k, v in sorted(entries.items())) + return parse_obj_as(cls, composed_string) diff --git a/packages/service-library/src/servicelib/rabbitmq/__init__.py b/packages/service-library/src/servicelib/rabbitmq/__init__.py index 091e69b7369..1b13c91efa8 100644 --- a/packages/service-library/src/servicelib/rabbitmq/__init__.py +++ b/packages/service-library/src/servicelib/rabbitmq/__init__.py @@ -1,3 +1,5 @@ +from models_library.rabbitmq_basic_types import RPCNamespace + from ._client import RabbitMQClient from ._client_rpc import RabbitMQRPCClient from ._constants import BIND_TO_ALL_TOPICS @@ -6,7 +8,7 @@ RPCNotInitializedError, RPCServerError, ) -from ._models import RPCMethodName, RPCNamespace +from ._models import RPCMethodName from ._rpc_router import RPCRouter from ._utils import wait_till_rabbitmq_responsive diff --git a/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py b/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py index 0e226fc8b2f..f8392ae6b01 100644 --- a/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py +++ b/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py @@ -6,13 +6,14 @@ from typing import Any import aio_pika +from models_library.rabbitmq_basic_types import RPCNamespace from pydantic import PositiveInt from settings_library.rabbit import RabbitSettings from ..logging_utils import log_context from ._client_base import RabbitMQClientBase from ._errors import RemoteMethodNotRegisteredError, RPCNotInitializedError -from ._models import RPCMethodName, RPCNamespace, RPCNamespacedMethodName +from ._models import RPCMethodName, RPCNamespacedMethodName from ._rpc_router import RPCRouter from ._utils import get_rabbitmq_client_unique_name diff --git a/packages/service-library/src/servicelib/rabbitmq/_constants.py b/packages/service-library/src/servicelib/rabbitmq/_constants.py index f47d029b3b4..07cc0b9378d 100644 --- a/packages/service-library/src/servicelib/rabbitmq/_constants.py +++ b/packages/service-library/src/servicelib/rabbitmq/_constants.py @@ -1,4 +1,3 @@ from typing import Final BIND_TO_ALL_TOPICS: Final[str] = "#" -REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS: Final[str] = r"^[\w\-\.]*$" diff --git a/packages/service-library/src/servicelib/rabbitmq/_models.py b/packages/service-library/src/servicelib/rabbitmq/_models.py index 3fd8aa3f37c..cb44eb0b3fe 100644 --- a/packages/service-library/src/servicelib/rabbitmq/_models.py +++ b/packages/service-library/src/servicelib/rabbitmq/_models.py @@ -2,10 +2,12 @@ from collections.abc import Awaitable, Callable from typing import Any, Protocol +from models_library.rabbitmq_basic_types import ( + REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS, + RPCNamespace, +) from pydantic import ConstrainedStr, parse_obj_as -from ._constants import REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS - MessageHandler = Callable[[Any], Awaitable[bool]] @@ -23,21 +25,6 @@ class RPCMethodName(ConstrainedStr): regex: re.Pattern[str] | None = re.compile(REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS) -class RPCNamespace(ConstrainedStr): - min_length: int = 1 - max_length: int = 252 - regex: re.Pattern[str] | None = re.compile(REGEX_RABBIT_QUEUE_ALLOWED_SYMBOLS) - - @classmethod - def from_entries(cls, entries: dict[str, str]) -> "RPCNamespace": - """ - Given a list of entries creates a namespace to be used in declaring the rabbitmq queue. - Keeping this to a predefined length - """ - composed_string = "-".join(f"{k}_{v}" for k, v in sorted(entries.items())) - return parse_obj_as(cls, composed_string) - - class RPCNamespacedMethodName(ConstrainedStr): min_length: int = 1 max_length: int = 255 diff --git a/services/payments/src/simcore_service_payments/api/rpc/routes.py b/services/payments/src/simcore_service_payments/api/rpc/routes.py index 218cec8e46e..720a9ec91eb 100644 --- a/services/payments/src/simcore_service_payments/api/rpc/routes.py +++ b/services/payments/src/simcore_service_payments/api/rpc/routes.py @@ -1,24 +1,17 @@ import logging -from typing import Final from fastapi import FastAPI -from pydantic import parse_obj_as -from servicelib.rabbitmq import RPCNamespace +from models_library.api_schemas_payments import PAYMENTS_RPC_NAMESPACE -from ...services.rabbitmq import get_rabbitmq_rpc_client, is_rabbitmq_enabled +from ...services.rabbitmq import get_rabbitmq_rpc_server from . import _payments _logger = logging.getLogger(__name__) -PAYMENTS_RPC_NAMESPACE: Final[RPCNamespace] = parse_obj_as(RPCNamespace, "payments") - def setup_rpc_api_routes(app: FastAPI) -> None: async def _on_startup() -> None: - if is_rabbitmq_enabled(app): - rpc_client = get_rabbitmq_rpc_client(app) - await rpc_client.register_router( - _payments.router, PAYMENTS_RPC_NAMESPACE, app - ) + rpc_server = get_rabbitmq_rpc_server(app) + await rpc_server.register_router(_payments.router, PAYMENTS_RPC_NAMESPACE, app) app.add_event_handler("startup", _on_startup) diff --git a/services/payments/src/simcore_service_payments/services/rabbitmq.py b/services/payments/src/simcore_service_payments/services/rabbitmq.py index 07bf2032fd3..5083c4f6b8a 100644 --- a/services/payments/src/simcore_service_payments/services/rabbitmq.py +++ b/services/payments/src/simcore_service_payments/services/rabbitmq.py @@ -43,11 +43,7 @@ def get_rabbitmq_client(app: FastAPI) -> RabbitMQClient: return cast(RabbitMQClient, app.state.rabbitmq_client) -def is_rabbitmq_enabled(app: FastAPI) -> bool: - return app.state.rabbitmq_client is not None - - -def get_rabbitmq_rpc_client(app: FastAPI) -> RabbitMQRPCClient: +def get_rabbitmq_rpc_server(app: FastAPI) -> RabbitMQRPCClient: assert app.state.rabbitmq_rpc_server # nosec return cast(RabbitMQRPCClient, app.state.rabbitmq_rpc_server) diff --git a/services/web/server/src/simcore_service_webserver/payments/_autorecharge_api.py b/services/web/server/src/simcore_service_webserver/payments/_autorecharge_api.py index cbd3b023312..649576ab601 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_autorecharge_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_autorecharge_api.py @@ -9,13 +9,13 @@ from models_library.users import UserID from models_library.wallets import WalletID -from ._api import check_wallet_permissions from ._autorecharge_db import ( PaymentsAutorechargeDB, get_wallet_autorecharge, replace_wallet_autorecharge, ) from ._methods_db import list_successful_payment_methods +from ._onetime_api import check_wallet_permissions from .settings import get_plugin_settings _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/payments/_client.py b/services/web/server/src/simcore_service_webserver/payments/_client.py deleted file mode 100644 index 75ccf7c44a7..00000000000 --- a/services/web/server/src/simcore_service_webserver/payments/_client.py +++ /dev/null @@ -1,135 +0,0 @@ -import asyncio -import contextlib -import logging -from dataclasses import dataclass -from decimal import Decimal -from uuid import uuid4 - -from aiohttp import BasicAuth, ClientSession, web -from aiohttp.client_exceptions import ClientError -from models_library.users import UserID -from yarl import URL - -from .._constants import APP_SETTINGS_KEY -from .settings import PaymentsSettings, get_plugin_settings - -_logger = logging.getLogger(__name__) - - -# -# CLIENT -# - - -@dataclass(frozen=True) -class PaymentsServiceApi: - client: ClientSession - settings: PaymentsSettings - exit_stack: contextlib.AsyncExitStack - healthcheck_path: str = "/" - - @classmethod - async def create(cls, settings: PaymentsSettings) -> "PaymentsServiceApi": - exit_stack = contextlib.AsyncExitStack() - client_session = await exit_stack.enter_async_context( - ClientSession( - auth=BasicAuth( - login=settings.PAYMENTS_USERNAME, - password=settings.PAYMENTS_PASSWORD.get_secret_value(), - ), - raise_for_status=True, - ) - ) - return cls(client=client_session, exit_stack=exit_stack, settings=settings) - - # - # common SDK - # - - def _url(self, rel_url: str): - return URL(self.settings.base_url) / rel_url.lstrip("/") - - def _url_vtag(self, rel_url: str): - return URL(self.settings.api_base_url) / rel_url.lstrip("/") - - # NOTE: the functions above are added due to limitations in - # aioresponses https://github.com/pnuckowski/aioresponses/issues/230 - # we will avoid using ClientSession(base_url=settings.base_url, ... ) and - # use insteald self._url("/v0/foo") - - async def close(self) -> None: - """Releases underlying connector from ClientSession [client]""" - await self.exit_stack.aclose() - - async def is_healthy(self) -> bool: - try: - response = await self.client.get(self._url(self.healthcheck_path)) - return bool(response.ok) - except ClientError as err: - _logger.debug("Payments service is not healty: %s", err) - return False - - -# NOTE: Functions below FAKE behaviour of payments service -async def create_fake_payment( - app: web.Application, - *, - price_dollars: Decimal, - osparc_credits: Decimal, - product_name: str, - user_id: UserID, - name: str, - email: str, -): - assert osparc_credits > 0 # nosec - assert name # nosec - assert email # nosec - assert product_name # nosec - assert price_dollars > 0 # nosec - - body = { - "price_dollars": price_dollars, - "osparc_credits": osparc_credits, - "user_id": user_id, - "name": name, - "email": email, - } - - # Fake response of payment service -------- - _logger.info("Sending -> payments-service %s", body) - await asyncio.sleep(1) - transaction_id = f"{uuid4()}" - # ------------- - - settings: PaymentsSettings = get_plugin_settings(app) - base_url = URL(settings.PAYMENTS_FAKE_GATEWAY_URL) - submission_link = base_url.with_path("/pay").with_query(id=transaction_id) - return submission_link, transaction_id - - -# -# EVENTS -# - -_APP_PAYMENTS_SERVICE_API_KEY = f"{__name__}.{PaymentsServiceApi.__name__}" - - -async def payments_service_api_cleanup_ctx(app: web.Application): - service_api = await PaymentsServiceApi.create( - settings=app[APP_SETTINGS_KEY].WEBSERVER_PAYMENTS - ) - - app[_APP_PAYMENTS_SERVICE_API_KEY] = service_api - - yield - - try: - await service_api.close() - except Exception: # pylint: disable=broad-except - _logger.warning("Ignored error while cleaning", exc_info=True) - - -def get_payments_service_api(app: web.Application) -> PaymentsServiceApi: - assert app[_APP_PAYMENTS_SERVICE_API_KEY] # nosec - service_api: PaymentsServiceApi = app[_APP_PAYMENTS_SERVICE_API_KEY] - return service_api diff --git a/services/web/server/src/simcore_service_webserver/payments/_methods_api.py b/services/web/server/src/simcore_service_webserver/payments/_methods_api.py index 6ae2d1cad2b..cacc699da27 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_methods_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_methods_api.py @@ -19,7 +19,6 @@ from simcore_postgres_database.models.payments_methods import InitPromptAckFlowState from yarl import URL -from ._api import check_wallet_permissions from ._autorecharge_db import get_wallet_autorecharge from ._methods_db import ( PaymentsMethodsDB, @@ -29,6 +28,7 @@ list_successful_payment_methods, udpate_payment_method, ) +from ._onetime_api import check_wallet_permissions from ._socketio import notify_payment_method_acked from .settings import PaymentsSettings, get_plugin_settings @@ -64,11 +64,6 @@ def _to_api_model( ) -# -# Payment-methods -# - - async def init_creation_of_wallet_payment_method( app: web.Application, *, @@ -119,7 +114,7 @@ async def init_creation_of_wallet_payment_method( ) -async def _complete_create_of_wallet_payment_method( +async def _ack_creation_of_wallet_payment_method( app: web.Application, *, payment_method_id: PaymentMethodID, @@ -164,7 +159,7 @@ async def cancel_creation_of_wallet_payment_method( app, user_id=user_id, wallet_id=wallet_id, product_name=product_name ) - await _complete_create_of_wallet_payment_method( + await _ack_creation_of_wallet_payment_method( app, payment_method_id=payment_method_id, completion_state=InitPromptAckFlowState.CANCELED, diff --git a/services/web/server/src/simcore_service_webserver/payments/_api.py b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py similarity index 78% rename from services/web/server/src/simcore_service_webserver/payments/_api.py rename to services/web/server/src/simcore_service_webserver/payments/_onetime_api.py index f5fa6f29f49..cd07994cf62 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py @@ -2,7 +2,6 @@ from decimal import Decimal from typing import Any -import arrow from aiohttp import web from models_library.api_schemas_webserver.wallets import ( PaymentID, @@ -21,8 +20,7 @@ from ..users.api import get_user_name_and_email from ..wallets.api import get_wallet_by_user, get_wallet_with_permissions_by_user from ..wallets.errors import WalletAccessForbiddenError -from . import _db -from ._client import create_fake_payment, get_payments_service_api +from . import _onetime_db, _rpc from ._socketio import notify_payment_completed _logger = logging.getLogger(__name__) @@ -43,7 +41,9 @@ async def check_wallet_permissions( ) -def _to_api_model(transaction: _db.PaymentsTransactionsDB) -> PaymentTransaction: +def _to_api_model( + transaction: _onetime_db.PaymentsTransactionsDB, +) -> PaymentTransaction: data: dict[str, Any] = { "payment_id": transaction.payment_id, "price_dollars": transaction.price_dollars, @@ -66,12 +66,7 @@ def _to_api_model(transaction: _db.PaymentsTransactionsDB) -> PaymentTransaction return PaymentTransaction.parse_obj(data) -# -# One-time Payments -# - - -async def create_payment_to_wallet( +async def init_creation_of_wallet_payment( app: web.Application, *, price_dollars: Decimal, @@ -87,72 +82,36 @@ async def create_payment_to_wallet( UserNotFoundError WalletAccessForbiddenError """ - # get user info - user = await get_user_name_and_email(app, user_id=user_id) - # check permissions + # wallet: check permissions await check_wallet_permissions( app, user_id=user_id, wallet_id=wallet_id, product_name=product_name ) + user_wallet = await get_wallet_by_user( + app, user_id=user_id, wallet_id=wallet_id, product_name=product_name + ) + assert user_wallet.wallet_id == wallet_id # nosec - # hold timestamp - initiated_at = arrow.utcnow().datetime + # user info + user = await get_user_name_and_email(app, user_id=user_id) - # payment service - # FAKE ------------ - submission_link, payment_id = await create_fake_payment( + # call to payment-service + payment_inited: WalletPaymentCreated = await _rpc.init_payment( app, - price_dollars=price_dollars, - product_name=product_name, - user_id=user_id, - name=user.name, - email=user.email, - osparc_credits=osparc_credits, - ) - # ----- - # gateway responded, we store the transaction - await _db.create_payment_transaction( - app, - payment_id=payment_id, - price_dollars=price_dollars, - osparc_credits=osparc_credits, + amount_dollars=price_dollars, + target_credits=osparc_credits, product_name=product_name, + wallet_id=wallet_id, + wallet_name=user_wallet.name, user_id=user_id, + user_name=user.name, user_email=user.email, - wallet_id=wallet_id, comment=comment, - initiated_at=initiated_at, - ) - - return WalletPaymentCreated( - payment_id=payment_id, - payment_form_url=f"{submission_link}", - ) - - -async def get_user_payments_page( - app: web.Application, - product_name: str, - user_id: UserID, - *, - limit: int, - offset: int, -) -> tuple[list[PaymentTransaction], int]: - assert limit > 1 # nosec - assert offset >= 0 # nosec - assert product_name # nosec - - payments_service = get_payments_service_api(app) - assert payments_service # nosec - - total_number_of_items, transactions = await _db.list_user_payment_transactions( - app, user_id=user_id, offset=offset, limit=limit ) - - return [_to_api_model(t) for t in transactions], total_number_of_items + return payment_inited -async def complete_payment( +async def _ack_creation_of_wallet_payment( app: web.Application, *, payment_id: PaymentID, @@ -160,8 +119,10 @@ async def complete_payment( message: str | None = None, invoice_url: HttpUrl | None = None, ) -> PaymentTransaction: - # NOTE: implements endpoint in payment service hit by the gateway - transaction = await _db.complete_payment_transaction( + # + # NOTE: implements endpoint in payment service hit by the gateway (ONLY for testing or fake completion!) + # + transaction = await _onetime_db.complete_payment_transaction( app, payment_id=payment_id, completion_state=completion_state, @@ -211,9 +172,31 @@ async def cancel_payment_to_wallet( app, user_id=user_id, wallet_id=wallet_id, product_name=product_name ) - return await complete_payment( + return await _ack_creation_of_wallet_payment( app, payment_id=payment_id, completion_state=PaymentTransactionState.CANCELED, message="Payment aborted by user", ) + + +async def list_user_payments_page( + app: web.Application, + product_name: str, + user_id: UserID, + *, + limit: int, + offset: int, +) -> tuple[list[PaymentTransaction], int]: + assert limit > 1 # nosec + assert offset >= 0 # nosec + assert product_name # nosec + + ( + total_number_of_items, + transactions, + ) = await _onetime_db.list_user_payment_transactions( + app, user_id=user_id, offset=offset, limit=limit + ) + + return [_to_api_model(t) for t in transactions], total_number_of_items diff --git a/services/web/server/src/simcore_service_webserver/payments/_db.py b/services/web/server/src/simcore_service_webserver/payments/_onetime_db.py similarity index 75% rename from services/web/server/src/simcore_service_webserver/payments/_db.py rename to services/web/server/src/simcore_service_webserver/payments/_onetime_db.py index f225293a147..9f94d46b707 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_db.py +++ b/services/web/server/src/simcore_service_webserver/payments/_onetime_db.py @@ -16,19 +16,13 @@ ) from simcore_postgres_database.utils_payments import ( PaymentAlreadyAcked, - PaymentAlreadyExists, PaymentNotFound, get_user_payments_transactions, - insert_init_payment_transaction, update_payment_transaction_state, ) from ..db.plugin import get_database_engine -from .errors import ( - PaymentCompletedError, - PaymentNotFoundError, - PaymentUniqueViolationError, -) +from .errors import PaymentCompletedError, PaymentNotFoundError _logger = logging.getLogger(__name__) @@ -55,40 +49,6 @@ class Config: orm_mode = True -async def create_payment_transaction( - app: web.Application, - *, - payment_id: str, - price_dollars: Decimal, - osparc_credits: Decimal, - product_name: str, - user_id: UserID, - user_email: str, - wallet_id: WalletID, - comment: str | None, - initiated_at: datetime.datetime, -) -> None: - async with get_database_engine(app).acquire() as conn: - ok = await insert_init_payment_transaction( - conn, - payment_id=payment_id, - price_dollars=price_dollars, - osparc_credits=osparc_credits, - product_name=product_name, - user_id=user_id, - user_email=user_email, - wallet_id=wallet_id, - comment=comment, - initiated_at=initiated_at, - ) - if isinstance(ok, PaymentAlreadyExists): - assert not ok # nosec - assert ok.payment_id == payment_id # nosec - raise PaymentUniqueViolationError(payment_id=payment_id) - - assert ok == payment_id # nosec - - async def list_user_payment_transactions( app, *, diff --git a/services/web/server/src/simcore_service_webserver/payments/_rpc.py b/services/web/server/src/simcore_service_webserver/payments/_rpc.py new file mode 100644 index 00000000000..7beb87c5675 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/payments/_rpc.py @@ -0,0 +1,78 @@ +""" RPC client-side for the RPC server at the payments service + +""" + +import logging +from decimal import Decimal + +from aiohttp import web +from models_library.api_schemas_payments import PAYMENTS_RPC_NAMESPACE +from models_library.api_schemas_webserver.wallets import WalletPaymentCreated +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import parse_obj_as +from servicelib.logging_utils import log_decorator +from servicelib.rabbitmq import RabbitMQRPCClient, RPCMethodName + +from ..rabbitmq_settings import RabbitSettings +from ..rabbitmq_settings import get_plugin_settings as get_rabbitmq_settings + +_logger = logging.getLogger(__name__) + + +_APP_PAYMENTS_RPC_CLIENT_KEY = f"{__name__}.RabbitMQRPCClient" + + +async def rabbitmq_rpc_client_lifespan(app: web.Application): + settings: RabbitSettings = get_rabbitmq_settings(app) + rpc_client = await RabbitMQRPCClient.create( + client_name="webserver_payments_client", + settings=settings, + ) + + assert rpc_client # nosec + + app[_APP_PAYMENTS_RPC_CLIENT_KEY] = rpc_client + + yield + + await rpc_client.close() + + +# +# rpc client functions +# + + +@log_decorator(_logger, level=logging.DEBUG) +async def init_payment( + app: web.Application, + *, + amount_dollars: Decimal, + target_credits: Decimal, + product_name: str, + wallet_id: WalletID, + wallet_name: str, + user_id: UserID, + user_name: str, + user_email: str, + comment: str | None = None, +) -> WalletPaymentCreated: + rpc_client = app[_APP_PAYMENTS_RPC_CLIENT_KEY] + + # NOTE: remote errors are aio_pika.MessageProcessError + result = await rpc_client.request( + PAYMENTS_RPC_NAMESPACE, + parse_obj_as(RPCMethodName, "init_payment"), + amount_dollars=amount_dollars, + target_credits=target_credits, + product_name=product_name, + wallet_id=wallet_id, + wallet_name=wallet_name, + user_id=user_id, + user_name=user_name, + user_email=user_email, + comment=comment, + ) + assert isinstance(result, WalletPaymentCreated) # nosec + return result diff --git a/services/web/server/src/simcore_service_webserver/payments/_tasks.py b/services/web/server/src/simcore_service_webserver/payments/_tasks.py index 344d313bc8a..9c18bf18df2 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_tasks.py +++ b/services/web/server/src/simcore_service_webserver/payments/_tasks.py @@ -16,12 +16,14 @@ from tenacity.before_sleep import before_sleep_log from tenacity.wait import wait_exponential -from ._api import complete_payment -from ._db import get_pending_payment_transactions_ids from ._methods_api import ( - _complete_create_of_wallet_payment_method, # pylint: disable=protected-access + _ack_creation_of_wallet_payment_method, # pylint: disable=protected-access ) from ._methods_db import get_pending_payment_methods_ids +from ._onetime_api import ( + _ack_creation_of_wallet_payment, # pylint: disable=protected-access +) +from ._onetime_db import get_pending_payment_transactions_ids from .settings import get_plugin_settings _logger = logging.getLogger(__name__) @@ -62,7 +64,7 @@ async def _fake_payment_completion(app: web.Application, payment_id: PaymentID): ) _logger.info("Faking payment completion as %s", kwargs) - await complete_payment(app, payment_id=payment_id, **kwargs) + await _ack_creation_of_wallet_payment(app, payment_id=payment_id, **kwargs) _POSSIBLE_PAYMENTS_METHODS_OUTCOMES = _create_possible_outcomes( @@ -89,7 +91,7 @@ async def _fake_payment_method_completion( ) _logger.info("Faking payment-method completion as %s", kwargs) - await _complete_create_of_wallet_payment_method( + await _ack_creation_of_wallet_payment_method( app, payment_method_id=payment_method_id, **kwargs ) diff --git a/services/web/server/src/simcore_service_webserver/payments/api.py b/services/web/server/src/simcore_service_webserver/payments/api.py index 142222053dc..4dbdd9d4a0b 100644 --- a/services/web/server/src/simcore_service_webserver/payments/api.py +++ b/services/web/server/src/simcore_service_webserver/payments/api.py @@ -1,13 +1,7 @@ -from ._api import ( - cancel_payment_to_wallet, - create_payment_to_wallet, - get_user_payments_page, -) from ._autorecharge_api import ( get_wallet_payment_autorecharge, replace_wallet_payment_autorecharge, ) -from ._client import get_payments_service_api from ._methods_api import ( cancel_creation_of_wallet_payment_method, delete_wallet_payment_method, @@ -15,14 +9,18 @@ init_creation_of_wallet_payment_method, list_wallet_payment_methods, ) +from ._onetime_api import ( + cancel_payment_to_wallet, + init_creation_of_wallet_payment, + list_user_payments_page, +) __all__: tuple[str, ...] = ( "cancel_creation_of_wallet_payment_method", "cancel_payment_to_wallet", - "create_payment_to_wallet", + "init_creation_of_wallet_payment", "delete_wallet_payment_method", - "get_payments_service_api", - "get_user_payments_page", + "list_user_payments_page", "get_wallet_payment_autorecharge", "get_wallet_payment_method", "init_creation_of_wallet_payment_method", diff --git a/services/web/server/src/simcore_service_webserver/payments/plugin.py b/services/web/server/src/simcore_service_webserver/payments/plugin.py index a81ce3d2caf..c8f943980ea 100644 --- a/services/web/server/src/simcore_service_webserver/payments/plugin.py +++ b/services/web/server/src/simcore_service_webserver/payments/plugin.py @@ -6,10 +6,11 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup +from simcore_service_webserver.rabbitmq import setup_rabbitmq from .._constants import APP_SETTINGS_KEY from ..db.plugin import setup_db -from ._client import payments_service_api_cleanup_ctx +from ._rpc import rabbitmq_rpc_client_lifespan from ._tasks import create_background_task_to_fake_payment_completion _logger = logging.getLogger(__name__) @@ -25,8 +26,9 @@ def setup_payments(app: web.Application): settings = app[APP_SETTINGS_KEY].WEBSERVER_PAYMENTS setup_db(app) + setup_rabbitmq(app) - app.cleanup_ctx.append(payments_service_api_cleanup_ctx) + app.cleanup_ctx.append(rabbitmq_rpc_client_lifespan) if settings.PAYMENTS_FAKE_COMPLETION: _logger.warning( diff --git a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py index 3b2ff8de67e..c8f2ad4af3f 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py @@ -26,12 +26,12 @@ from ..payments import api from ..payments.api import ( cancel_creation_of_wallet_payment_method, - create_payment_to_wallet, delete_wallet_payment_method, - get_user_payments_page, get_wallet_payment_autorecharge, get_wallet_payment_method, + init_creation_of_wallet_payment, init_creation_of_wallet_payment_method, + list_user_payments_page, list_wallet_payment_methods, replace_wallet_payment_autorecharge, ) @@ -76,7 +76,7 @@ async def create_payment(request: web.Request): # '0 or None' should raise raise web.HTTPConflict(reason=MSG_PRICE_NOT_DEFINED_ERROR) - payment: WalletPaymentCreated = await create_payment_to_wallet( + payment: WalletPaymentCreated = await init_creation_of_wallet_payment( request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, @@ -104,7 +104,7 @@ async def list_all_payments(request: web.Request): req_ctx = WalletsRequestContext.parse_obj(request) query_params = parse_request_query_parameters_as(PageQueryParameters, request) - payments, total_number_of_items = await get_user_payments_page( + payments, total_number_of_items = await list_user_payments_page( request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, diff --git a/services/web/server/tests/unit/with_dbs/03/wallets/payments/conftest.py b/services/web/server/tests/unit/with_dbs/03/wallets/payments/conftest.py index bbfb6fab68a..1b6f3d42ca5 100644 --- a/services/web/server/tests/unit/with_dbs/03/wallets/payments/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/wallets/payments/conftest.py @@ -6,15 +6,17 @@ from collections.abc import Callable -from typing import Any, TypeAlias +from typing import Any, Iterator, TypeAlias import pytest +import sqlalchemy as sa from aiohttp import web from aiohttp.test_utils import TestClient from faker import Faker from models_library.api_schemas_webserver.wallets import WalletGet from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_login import UserInfoDict +from simcore_postgres_database.models.payments_transactions import payments_transactions from simcore_service_webserver.db.models import UserRole OpenApiDict: TypeAlias = dict[str, Any] @@ -53,3 +55,10 @@ async def logged_user_wallet( ) -> WalletGet: assert client.app return await create_new_wallet() + + +@pytest.fixture +def payments_transactions_clean_db(postgres_db: sa.engine.Engine) -> Iterator[None]: + with postgres_db.connect() as con: + yield + con.execute(payments_transactions.delete()) diff --git a/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py index db871ec9dbd..52d03101bfd 100644 --- a/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py +++ b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py @@ -1,11 +1,12 @@ +# 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 - from decimal import Decimal from typing import Any, TypeAlias +from unittest.mock import Mock import pytest from aiohttp import web @@ -17,19 +18,27 @@ WalletPaymentCreated, ) from models_library.rest_pagination import Page +from models_library.users import UserID +from models_library.wallets import WalletID from pydantic import parse_obj_as from pytest_mock import MockerFixture +from pytest_simcore.helpers.rawdata_fakers import utcnow from pytest_simcore.helpers.utils_assert import assert_status -from pytest_simcore.helpers.utils_login import LoggedUser +from pytest_simcore.helpers.utils_login import LoggedUser, UserInfoDict from simcore_postgres_database.models.payments_transactions import ( PaymentTransactionState, ) -from simcore_service_webserver.payments._api import complete_payment +from simcore_postgres_database.utils_payments import insert_init_payment_transaction +from simcore_service_webserver.db.plugin import get_database_engine +from simcore_service_webserver.payments._onetime_api import ( + _ack_creation_of_wallet_payment, +) from simcore_service_webserver.payments.errors import PaymentCompletedError from simcore_service_webserver.payments.settings import ( PaymentsSettings, get_plugin_settings, ) +from yarl import URL OpenApiDict: TypeAlias = dict[str, Any] @@ -55,6 +64,63 @@ async def test_payment_on_invalid_wallet( assert error +@pytest.fixture +def mock_rpc_payments_service_api( + mocker: MockerFixture, faker: Faker, payments_transactions_clean_db: None +) -> dict[str, Mock]: + async def _fake_rpc_init_payment( + app: web.Application, + *, + amount_dollars: Decimal, + target_credits: Decimal, + product_name: str, + wallet_id: WalletID, + wallet_name: str, + user_id: UserID, + user_name: str, + user_email: str, + comment: str | None = None, + ): + # EMULATES services/payments/src/simcore_service_payments/api/rpc/_payments.py + # (1) Init payment + payment_id = faker.uuid4() + # get_form_payment_url + settings: PaymentsSettings = get_plugin_settings(app) + external_form_link = ( + URL(settings.PAYMENTS_FAKE_GATEWAY_URL) + .with_path("/pay") + .with_query(id=payment_id) + ) + # (2) Annotate INIT transaction + async with get_database_engine(app).acquire() as conn: + assert ( + await insert_init_payment_transaction( + conn, + payment_id=payment_id, + price_dollars=amount_dollars, + osparc_credits=target_credits, + product_name=product_name, + user_id=user_id, + user_email=user_email, + wallet_id=wallet_id, + comment=comment, + initiated_at=utcnow(), + ) + == payment_id + ) + return WalletPaymentCreated( + payment_id=payment_id, payment_form_url=f"{external_form_link}" + ) + + mock_init_payment = mocker.patch( + "simcore_service_webserver.payments._onetime_api._rpc.init_payment", + autospec=True, + side_effect=_fake_rpc_init_payment, + ) + + return {"init_payment": mock_init_payment} + + @pytest.mark.acceptance_test( "For https://github.com/ITISFoundation/osparc-simcore/issues/4657" ) @@ -64,6 +130,7 @@ async def test_payments_worfklow( logged_user_wallet: WalletGet, mocker: MockerFixture, faker: Faker, + mock_rpc_payments_service_api: dict[str, Mock], ): assert client.app settings: PaymentsSettings = get_plugin_settings(client.app) @@ -73,8 +140,9 @@ async def test_payments_worfklow( send_message = mocker.patch( "simcore_service_webserver.payments._socketio.send_messages", autospec=True ) - mock_add_credits_to_wallet = mocker.patch( - "simcore_service_webserver.payments._api.add_credits_to_wallet", autospec=True + mock_rut_add_credits_to_wallet = mocker.patch( + "simcore_service_webserver.payments._onetime_api.add_credits_to_wallet", + autospec=True, ) wallet = logged_user_wallet @@ -94,9 +162,10 @@ async def test_payments_worfklow( assert payment.payment_form_url.host == "some-fake-gateway.com" assert payment.payment_form_url.query assert payment.payment_form_url.query.endswith(payment.payment_id) + assert mock_rpc_payments_service_api["init_payment"].called # Complete - await complete_payment( + await _ack_creation_of_wallet_payment( client.app, payment_id=payment.payment_id, completion_state=PaymentTransactionState.SUCCESS, @@ -104,8 +173,8 @@ async def test_payments_worfklow( ) # check notification to RUT - assert mock_add_credits_to_wallet.called - mock_add_credits_to_wallet.assert_called_once() + assert mock_rut_add_credits_to_wallet.called + mock_rut_add_credits_to_wallet.assert_called_once() # check notification assert send_message.called @@ -136,6 +205,7 @@ async def test_multiple_payments( logged_user_wallet: WalletGet, mocker: MockerFixture, faker: Faker, + mock_rpc_payments_service_api: dict[str, Mock], ): assert client.app settings: PaymentsSettings = get_plugin_settings(client.app) @@ -145,8 +215,9 @@ async def test_multiple_payments( send_message = mocker.patch( "simcore_service_webserver.payments._socketio.send_messages", autospec=True ) - mock_add_credits_to_wallet = mocker.patch( - "simcore_service_webserver.payments._api.add_credits_to_wallet", autospec=True + mocker.patch( + "simcore_service_webserver.payments._onetime_api.add_credits_to_wallet", + autospec=True, ) wallet = logged_user_wallet @@ -171,7 +242,7 @@ async def test_multiple_payments( payment = WalletPaymentCreated.parse_obj(data) if n % 2: - transaction = await complete_payment( + transaction = await _ack_creation_of_wallet_payment( client.app, payment_id=payment.payment_id, completion_state=PaymentTransactionState.SUCCESS, @@ -222,6 +293,7 @@ async def test_complete_payment_errors( client: TestClient, logged_user_wallet: WalletGet, mocker: MockerFixture, + mock_rpc_payments_service_api: dict[str, Mock], ): assert client.app send_message = mocker.patch( @@ -235,12 +307,15 @@ async def test_complete_payment_errors( f"/v0/wallets/{wallet.wallet_id}/payments", json={"priceDollars": 25}, ) + + assert mock_rpc_payments_service_api["init_payment"].called + data, _ = await assert_status(response, web.HTTPCreated) payment = WalletPaymentCreated.parse_obj(data) # Cannot complete as PENDING with pytest.raises(ValueError): - await complete_payment( + await _ack_creation_of_wallet_payment( client.app, payment_id=payment.payment_id, completion_state=PaymentTransactionState.PENDING, @@ -248,7 +323,7 @@ async def test_complete_payment_errors( send_message.assert_not_called() # Complete w/ failures - await complete_payment( + await _ack_creation_of_wallet_payment( client.app, payment_id=payment.payment_id, completion_state=PaymentTransactionState.FAILED, @@ -257,7 +332,7 @@ async def test_complete_payment_errors( # Cannot complete twice with pytest.raises(PaymentCompletedError): - await complete_payment( + await _ack_creation_of_wallet_payment( client.app, payment_id=payment.payment_id, completion_state=PaymentTransactionState.SUCCESS, @@ -286,22 +361,24 @@ async def test_payment_not_found( assert ":cancel" not in error_msg -def test_models_state_in_sync(): - state_type = PaymentTransaction.__fields__["state"].type_ +def test_payment_transaction_state_and_literals_are_in_sync(): + state_literals = PaymentTransaction.__fields__["state"].type_ assert ( - parse_obj_as(list[state_type], [f"{s}" for s in PaymentTransactionState]) + parse_obj_as(list[state_literals], [f"{s}" for s in PaymentTransactionState]) is not None ) async def test_payment_on_wallet_without_access( latest_osparc_price: Decimal, + logged_user: UserInfoDict, logged_user_wallet: WalletGet, client: TestClient, ): other_wallet = logged_user_wallet async with LoggedUser(client) as new_logged_user: + assert new_logged_user["email"] != logged_user["email"] response = await client.post( f"/v0/wallets/{other_wallet.wallet_id}/payments", json={ diff --git a/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments_methods.py b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments_methods.py index 98efbab5008..61b380efafc 100644 --- a/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments_methods.py +++ b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments_methods.py @@ -20,7 +20,7 @@ from pytest_simcore.helpers.utils_assert import assert_status from simcore_postgres_database.models.payments_methods import InitPromptAckFlowState from simcore_service_webserver.payments._methods_api import ( - _complete_create_of_wallet_payment_method, + _ack_creation_of_wallet_payment_method, ) from simcore_service_webserver.payments.settings import ( PaymentsSettings, @@ -67,7 +67,7 @@ async def test_payment_method_worfklow( await assert_status(response, web.HTTPNotFound) # Ack - await _complete_create_of_wallet_payment_method( + await _ack_creation_of_wallet_payment_method( client.app, payment_method_id=inited.payment_method_id, completion_state=InitPromptAckFlowState.SUCCESS, @@ -147,7 +147,7 @@ async def _add_payment_method( data, error = await assert_status(response, web.HTTPAccepted) assert error is None inited = PaymentMethodInit.parse_obj(data) - await _complete_create_of_wallet_payment_method( + await _ack_creation_of_wallet_payment_method( client.app, payment_method_id=inited.payment_method_id, completion_state=InitPromptAckFlowState.SUCCESS,