From 5477e3d417bf69017cd153fd595c004761798969 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 16 Nov 2023 09:59:25 +0100 Subject: [PATCH] =?UTF-8?q?=20=E2=9C=A8=20New=20`pay=20with=20payment-meth?= =?UTF-8?q?od`=20implementation=20(#5017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .vscode/extensions.json | 2 + api/specs/web-server/_wallets.py | 4 +- .../api_schemas_webserver/wallets.py | 26 ++-- .../pytest_simcore/helpers/rawdata_fakers.py | 6 +- services/payments/Makefile | 4 +- services/payments/VERSION | 2 +- services/payments/openapi.json | 14 +- services/payments/scripts/Makefile | 2 +- .../payments/scripts/fake_payment_gateway.py | 13 +- services/payments/setup.cfg | 4 +- .../api/rest/_dependencies.py | 3 +- .../api/rpc/_payments_methods.py | 7 +- .../db/payments_transactions_repo.py | 2 +- .../src/simcore_service_payments/models/db.py | 28 +++- .../models/payments_gateway.py | 15 +- .../models/schemas/acknowledgements.py | 51 ++++-- .../simcore_service_payments/models/utils.py | 3 - .../services/payments.py | 67 +++++--- .../services/payments_gateway.py | 63 +++++++- .../services/postgres.py | 6 + services/payments/tests/unit/conftest.py | 46 ++++-- .../tests/unit/test_rpc_payments_methods.py | 18 +-- .../unit/test_services_payments_gateway.py | 23 ++- .../paymentMethods/PaymentMethodDetails.js | 5 +- .../api/v0/openapi.yaml | 27 +--- .../payments/_methods_api.py | 5 +- .../payments/_onetime_api.py | 145 ++++++++++++++---- .../payments/_rpc.py | 11 +- .../simcore_service_webserver/payments/api.py | 8 +- .../wallets/_payments_handlers.py | 80 ++++++---- .../with_dbs/03/wallets/payments/conftest.py | 31 ++-- .../wallets/payments/test_payments_methods.py | 22 +-- 33 files changed, 489 insertions(+), 255 deletions(-) diff --git a/.gitignore b/.gitignore index ec92564a31c..9836532c4c9 100644 --- a/.gitignore +++ b/.gitignore @@ -178,5 +178,6 @@ Untitled* # service settings.schemas.json services/**/settings-schema.json +services/payments/scripts/openapi.json tests/public-api/osparc_python_wheels/* diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 65f20197c67..02968d94c72 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ + "42Crunch.vscode-openapi", "charliermarsh.ruff", "eamodio.gitlens", "exiasr.hadolint", @@ -7,6 +8,7 @@ "ms-python.black-formatter", "ms-python.pylint", "ms-python.python", + "ms-vscode.makefile-tools", "njpwerner.autodocstring", "samuelcolvin.jinjahtml", "timonwong.shellcheck", diff --git a/api/specs/web-server/_wallets.py b/api/specs/web-server/_wallets.py index c93054a2cec..9a0643073d2 100644 --- a/api/specs/web-server/_wallets.py +++ b/api/specs/web-server/_wallets.py @@ -168,10 +168,10 @@ async def delete_payment_method( @router.post( "/wallets/{wallet_id}/payments-methods/{payment_method_id}:pay", response_model=Envelope[WalletPaymentInitiated], - response_description="Payment initialized", + response_description="Pay with payment-method", status_code=status.HTTP_202_ACCEPTED, ) -async def init_payment_with_payment_method( +async def pay_with_payment_method( wallet_id: WalletID, payment_method_id: PaymentMethodID, _body: CreateWalletPayment ): ... diff --git a/packages/models-library/src/models_library/api_schemas_webserver/wallets.py b/packages/models-library/src/models_library/api_schemas_webserver/wallets.py index 47d328b1df2..85a96b73e50 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/wallets.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/wallets.py @@ -4,7 +4,7 @@ from pydantic import Field, HttpUrl -from ..basic_types import IDStr, NonNegativeDecimal +from ..basic_types import AmountDecimal, IDStr, NonNegativeDecimal from ..users import GroupID from ..utils.pydantic_tools_extension import FieldNotRequired from ..wallets import WalletID, WalletStatus @@ -55,7 +55,7 @@ class PutWalletBodyParams(OutputSchema): class CreateWalletPayment(InputSchema): - price_dollars: Decimal + price_dollars: AmountDecimal comment: str = FieldNotRequired(max_length=100) @@ -124,14 +124,11 @@ class Config(OutputSchema.Config): class PaymentMethodGet(OutputSchema): idr: PaymentMethodID wallet_id: WalletID - card_holder_name: str - card_number_masked: str - card_type: str - expiration_month: int - expiration_year: int - street_address: str - zipcode: str - country: str + card_holder_name: str | None = None + card_number_masked: str | None = None + card_type: str | None = None + expiration_month: int | None = None + expiration_year: int | None = None created: datetime auto_recharge: bool = Field( default=False, @@ -149,12 +146,15 @@ class Config(OutputSchema.Config): "cardType": "Visa", "expirationMonth": 10, "expirationYear": 2025, - "streetAddress": "123 Main St", - "zipcode": "12345", - "country": "United States", "created": "2023-09-13T15:30:00Z", "autoRecharge": "False", }, + { + "idr": "pm_1234567890", + "walletId": 3, + "created": "2024-09-13T15:30:00Z", + "autoRecharge": "False", + }, ], } diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py index 0b5564f9d20..036d0a9c126 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py @@ -251,16 +251,14 @@ def random_api_key(product_name: str, user_id: int, **overrides) -> dict[str, An def random_payment_method_data(**overrides) -> dict[str, Any]: # Produces data for GetPaymentMethod data = { - "idr": FAKE.uuid4(), + "id": FAKE.uuid4(), "card_holder_name": FAKE.name(), "card_number_masked": f"**** **** **** {FAKE.credit_card_number()[:4]}", "card_type": FAKE.credit_card_provider(), "expiration_month": FAKE.random_int(min=1, max=12), "expiration_year": FAKE.future_date().year, - "street_address": FAKE.street_address(), - "zipcode": FAKE.zipcode(), - "country": FAKE.country(), "created": utcnow(), } + assert set(overrides.keys()).issubset(data.keys()) data.update(**overrides) return data diff --git a/services/payments/Makefile b/services/payments/Makefile index db3152765a6..0af63b77c0c 100644 --- a/services/payments/Makefile +++ b/services/payments/Makefile @@ -23,8 +23,8 @@ external ?= .env-secret test-dev-unit-external: ## runs test-dev against external service defined in $(external) envfile # Running tests using external environ '$(external)' - $(MAKE) test-dev-unit pytest-parameters="--external-envfile=$(external)" + $(MAKE) test-dev-unit pytest-parameters="--external-envfile=$(external) -m can_run_against_external" test-ci-unit-external: ## runs test-ci against external service defined in $(external) envfile # Running tests using external environ '$(external)' - $(MAKE) test-ci-unit pytest-parameters="--external-envfile=$(external)" + $(MAKE) test-ci-unit pytest-parameters="--external-envfile=$(external) -m can_run_against_external" diff --git a/services/payments/VERSION b/services/payments/VERSION index 3a3cd8cc8b0..88c5fb891dc 100644 --- a/services/payments/VERSION +++ b/services/payments/VERSION @@ -1 +1 @@ -1.3.1 +1.4.0 diff --git a/services/payments/openapi.json b/services/payments/openapi.json index 692374075ef..0a0590edf31 100644 --- a/services/payments/openapi.json +++ b/services/payments/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "simcore-service-payments web API", "description": " Service that manages creation and validation of registration payments", - "version": "1.3.1" + "version": "1.4.0" }, "paths": { "/": { @@ -220,6 +220,13 @@ "type": "string", "title": "Message" }, + "provider_payment_id": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Provider Payment Id", + "description": "Payment ID from the provider (e.g. stripe payment ID)" + }, "invoice_url": { "type": "string", "maxLength": 2083, @@ -235,7 +242,7 @@ } ], "title": "Saved", - "description": "If the user decided to save the payment methodafter payment it returns the payment-method acknoledgement response.Otherwise it defaults to None." + "description": "Gets the payment-method if user opted to save it during payment.If used did not opt to save of payment-method was already saved, then it defaults to None" } }, "type": "object", @@ -245,10 +252,11 @@ "title": "AckPayment", "example": { "success": true, + "provider_payment_id": "pi_123ABC", "invoice_url": "https://invoices.com/id=12345", "saved": { "success": true, - "payment_method_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + "payment_method_id": "3FA85F64-5717-4562-B3FC-2C963F66AFA6" } } }, diff --git a/services/payments/scripts/Makefile b/services/payments/scripts/Makefile index 78a4a19b81f..166c0763f9d 100644 --- a/services/payments/scripts/Makefile +++ b/services/payments/scripts/Makefile @@ -9,6 +9,6 @@ run-devel: ## runs fake_payment_gateway server uvicorn fake_payment_gateway:the_app --reload -openapi.json: +openapi.json: ## creates OAS @set -o allexport; source .env-secret; set +o allexport; \ python fake_payment_gateway.py openapi > $@ diff --git a/services/payments/scripts/fake_payment_gateway.py b/services/payments/scripts/fake_payment_gateway.py index 883ed6d8845..0df5e7b76f2 100644 --- a/services/payments/scripts/fake_payment_gateway.py +++ b/services/payments/scripts/fake_payment_gateway.py @@ -39,7 +39,10 @@ PaymentMethodInitiated, PaymentMethodsBatch, ) -from simcore_service_payments.models.schemas.acknowledgements import AckPayment +from simcore_service_payments.models.schemas.acknowledgements import ( + AckPayment, + AckPaymentWithPaymentMethod, +) from simcore_service_payments.models.schemas.auth import Token logging.basicConfig(level=logging.INFO) @@ -291,7 +294,7 @@ def batch_get_payment_methods( @router.get( "/{id}", - response_class=GetPaymentMethod, + response_model=GetPaymentMethod, responses=ERROR_RESPONSES, ) def get_payment_method( @@ -315,10 +318,10 @@ def delete_payment_method( @router.post( "/{id}:pay", - response_model=PaymentInitiated, + response_model=AckPaymentWithPaymentMethod, responses=ERROR_RESPONSES, ) - def init_payment_with_payment_method( + def pay_with_payment_method( id: PaymentMethodID, payment: InitPayment, auth: Annotated[int, Depends(auth_session)], @@ -344,7 +347,7 @@ async def _app_lifespan(app: FastAPI): def create_app(): app = FastAPI( title="fake-payment-gateway", - version="0.2.1", + version="0.3.0", lifespan=_app_lifespan, debug=True, ) diff --git a/services/payments/setup.cfg b/services/payments/setup.cfg index 7a52ccb4934..cb32927a81b 100644 --- a/services/payments/setup.cfg +++ b/services/payments/setup.cfg @@ -1,14 +1,16 @@ [bumpversion] -current_version = 1.3.1 +current_version = 1.4.0 commit = True message = services/payments version: {current_version} → {new_version} tag = False commit_args = --no-verify [bumpversion:file:VERSION] +[bumpversion:file:openapi.json] [tool:pytest] asyncio_mode = auto markers = testit: "marks test to run during development" acceptance_test: "marks tests as 'acceptance tests' i.e. does the system do what the user expects? Typically those are workflows." + can_run_against_external: "marks tests that *can* be run against an external configuration passed by --external-envfile" diff --git a/services/payments/src/simcore_service_payments/api/rest/_dependencies.py b/services/payments/src/simcore_service_payments/api/rest/_dependencies.py index 8388242d820..773f215cbba 100644 --- a/services/payments/src/simcore_service_payments/api/rest/_dependencies.py +++ b/services/payments/src/simcore_service_payments/api/rest/_dependencies.py @@ -12,6 +12,7 @@ from ...db.base import BaseRepository from ...models.auth import SessionData from ...services.auth import get_session_data +from ...services.postgres import get_engine from ...services.resource_usage_tracker import ResourceUsageTrackerApi _logger = logging.getLogger(__name__) @@ -43,7 +44,7 @@ def get_rut_api(request: Request) -> ResourceUsageTrackerApi: def get_db_engine(request: Request) -> AsyncEngine: - engine: AsyncEngine = request.app.state.engine + engine: AsyncEngine = get_engine(request.app) assert engine # nosec return engine diff --git a/services/payments/src/simcore_service_payments/api/rpc/_payments_methods.py b/services/payments/src/simcore_service_payments/api/rpc/_payments_methods.py index 5b02867ef57..ef531a5b585 100644 --- a/services/payments/src/simcore_service_payments/api/rpc/_payments_methods.py +++ b/services/payments/src/simcore_service_payments/api/rpc/_payments_methods.py @@ -6,7 +6,6 @@ PaymentMethodGet, PaymentMethodID, PaymentMethodInitiated, - WalletPaymentInitiated, ) from models_library.basic_types import IDStr from models_library.users import UserID @@ -113,7 +112,7 @@ async def delete_payment_method( @router.expose() -async def init_payment_with_payment_method( # noqa: PLR0913 # pylint: disable=too-many-arguments +async def pay_with_payment_method( # noqa: PLR0913 # pylint: disable=too-many-arguments app: FastAPI, *, payment_method_id: PaymentMethodID, @@ -126,8 +125,8 @@ async def init_payment_with_payment_method( # noqa: PLR0913 # pylint: disable=t user_name: str, user_email: EmailStr, comment: str | None = None, -) -> WalletPaymentInitiated: - return await payments.init_payment_with_payment_method( +): + return await payments.pay_with_payment_method( gateway=PaymentsGatewayApi.get_from_app_state(app), repo_transactions=PaymentsTransactionsRepo(db_engine=app.state.engine), repo_methods=PaymentsMethodsRepo(db_engine=app.state.engine), diff --git a/services/payments/src/simcore_service_payments/db/payments_transactions_repo.py b/services/payments/src/simcore_service_payments/db/payments_transactions_repo.py index 0b7e972be76..8cfde93980d 100644 --- a/services/payments/src/simcore_service_payments/db/payments_transactions_repo.py +++ b/services/payments/src/simcore_service_payments/db/payments_transactions_repo.py @@ -36,10 +36,10 @@ async def insert_init_payment_transaction( initiated_at: datetime.datetime, ) -> PaymentID: """Annotates init-payment transaction + Raises: PaymentAlreadyExistsError """ - try: async with self.db_engine.begin() as conn: await conn.execute( diff --git a/services/payments/src/simcore_service_payments/models/db.py b/services/payments/src/simcore_service_payments/models/db.py index be7f858bfe2..85fbea0c160 100644 --- a/services/payments/src/simcore_service_payments/models/db.py +++ b/services/payments/src/simcore_service_payments/models/db.py @@ -2,7 +2,11 @@ from decimal import Decimal from typing import Any, ClassVar -from models_library.api_schemas_webserver.wallets import PaymentID, PaymentMethodID +from models_library.api_schemas_webserver.wallets import ( + PaymentID, + PaymentMethodID, + PaymentTransaction, +) from models_library.emails import LowerCaseEmailStr from models_library.products import ProductName from models_library.users import UserID @@ -59,6 +63,28 @@ class Config: ] } + def to_api_model(self) -> PaymentTransaction: + data: dict[str, Any] = { + "payment_id": self.payment_id, + "price_dollars": self.price_dollars, + "osparc_credits": self.osparc_credits, + "wallet_id": self.wallet_id, + "created_at": self.initiated_at, + "state": self.state, + "completed_at": self.completed_at, + } + + if self.comment: + data["comment"] = self.comment + + if self.state_message: + data["state_message"] = self.state_message + + if self.invoice_url: + data["invoice_url"] = self.invoice_url + + return PaymentTransaction.parse_obj(data) + _EXAMPLE_AFTER_INIT_PAYMENT_METHOD = { "payment_method_id": "12345", diff --git a/services/payments/src/simcore_service_payments/models/payments_gateway.py b/services/payments/src/simcore_service_payments/models/payments_gateway.py index b21c118d0f4..6614f67d303 100644 --- a/services/payments/src/simcore_service_payments/models/payments_gateway.py +++ b/services/payments/src/simcore_service_payments/models/payments_gateway.py @@ -51,15 +51,12 @@ class PaymentMethodInitiated(BaseModel): class GetPaymentMethod(BaseModel): - idr: PaymentMethodID - card_holder_name: str - card_number_masked: str - card_type: str - expiration_month: int - expiration_year: int - street_address: str - zipcode: str - country: str + id: PaymentMethodID + card_holder_name: str | None = None + card_number_masked: str | None = None + card_type: str | None = None + expiration_month: int | None = None + expiration_year: int | None = None created: datetime diff --git a/services/payments/src/simcore_service_payments/models/schemas/acknowledgements.py b/services/payments/src/simcore_service_payments/models/schemas/acknowledgements.py index ac08098a2cd..59ef23ed51b 100644 --- a/services/payments/src/simcore_service_payments/models/schemas/acknowledgements.py +++ b/services/payments/src/simcore_service_payments/models/schemas/acknowledgements.py @@ -1,6 +1,7 @@ from typing import Any, ClassVar from models_library.api_schemas_webserver.wallets import PaymentID, PaymentMethodID +from models_library.basic_types import IDStr from pydantic import BaseModel, Field, HttpUrl, validator @@ -9,6 +10,17 @@ class _BaseAck(BaseModel): message: str = Field(default=None) +class _BaseAckPayment(_BaseAck): + provider_payment_id: IDStr = Field( + default=None, + description="Payment ID from the provider (e.g. stripe payment ID)", + ) + + invoice_url: HttpUrl | None = Field( + default=None, description="Link to invoice is required when success=true" + ) + + # # ACK payment-methods # @@ -22,8 +34,13 @@ class SavedPaymentMethod(AckPaymentMethod): payment_method_id: PaymentMethodID +# +# ACK payments +# + _ONE_TIME_SUCCESS: dict[str, Any] = { "success": True, + "provider_payment_id": "pi_123ABC", "invoice_url": "https://invoices.com/id=12345", } _EXAMPLES: list[dict[str, Any]] = [ @@ -34,7 +51,7 @@ class SavedPaymentMethod(AckPaymentMethod): **_ONE_TIME_SUCCESS, "saved": { "success": True, - "payment_method_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "payment_method_id": "3FA85F64-5717-4562-B3FC-2C963F66AFA6", }, }, # 2. one-time-payment successful but payment-method-saved failed @@ -53,20 +70,12 @@ class SavedPaymentMethod(AckPaymentMethod): ] -# -# ACK one-time payments -# +class AckPayment(_BaseAckPayment): - -class AckPayment(_BaseAck): - invoice_url: HttpUrl | None = Field( - default=None, description="Link to invoice is required when success=true" - ) saved: SavedPaymentMethod | None = Field( default=None, - description="If the user decided to save the payment method" - "after payment it returns the payment-method acknoledgement response." - "Otherwise it defaults to None.", + description="Gets the payment-method if user opted to save it during payment." + "If used did not opt to save of payment-method was already saved, then it defaults to None", ) class Config: @@ -85,6 +94,24 @@ def success_requires_invoice(cls, v, values): return v +class AckPaymentWithPaymentMethod(_BaseAckPayment): + # NOTE: This model is equivalent to `AckPayment`, nonetheless + # I decided to separate it for clarity in the OAS since in payments + # w/ payment-method the field `saved` will never be provided, + + payment_id: PaymentID = Field( + default=None, description="Payment ID from the gateway" + ) + + class Config: + schema_extra: ClassVar[dict[str, Any]] = { + "example": { + **_ONE_TIME_SUCCESS, + "payment_id": "D19EE68B-B007-4B61-A8BC-32B7115FB244", + }, # shown in openapi.json + } + + assert PaymentID # nosec assert PaymentMethodID # nosec diff --git a/services/payments/src/simcore_service_payments/models/utils.py b/services/payments/src/simcore_service_payments/models/utils.py index 3326ca1be08..af5cbfe537e 100644 --- a/services/payments/src/simcore_service_payments/models/utils.py +++ b/services/payments/src/simcore_service_payments/models/utils.py @@ -15,9 +15,6 @@ def merge_models(got: GetPaymentMethod, acked: PaymentsMethodsDB) -> PaymentMeth card_type=got.card_type, expiration_month=got.expiration_month, expiration_year=got.expiration_year, - street_address=got.street_address, - zipcode=got.zipcode, - country=got.country, created=acked.completed_at, auto_recharge=False, # this will be fileld in the web/server ) diff --git a/services/payments/src/simcore_service_payments/services/payments.py b/services/payments/src/simcore_service_payments/services/payments.py index dcb0dceac17..5fa4f374c33 100644 --- a/services/payments/src/simcore_service_payments/services/payments.py +++ b/services/payments/src/simcore_service_payments/services/payments.py @@ -6,12 +6,14 @@ # pylint: disable=too-many-arguments import logging +import uuid from decimal import Decimal import arrow from models_library.api_schemas_webserver.wallets import ( PaymentID, PaymentMethodID, + PaymentTransaction, WalletPaymentInitiated, ) from models_library.users import UserID @@ -22,13 +24,20 @@ PaymentTransactionState, ) from simcore_service_payments.db.payments_methods_repo import PaymentsMethodsRepo +from tenacity import AsyncRetrying +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_attempt from .._constants import RUT -from ..core.errors import PaymentAlreadyAckedError, PaymentNotFoundError +from ..core.errors import ( + PaymentAlreadyAckedError, + PaymentAlreadyExistsError, + PaymentNotFoundError, +) from ..db.payments_transactions_repo import PaymentsTransactionsRepo from ..models.db import PaymentsTransactionsDB from ..models.payments_gateway import InitPayment, PaymentInitiated -from ..models.schemas.acknowledgements import AckPayment +from ..models.schemas.acknowledgements import AckPayment, AckPaymentWithPaymentMethod from ..services.resource_usage_tracker import ResourceUsageTrackerApi from .payments_gateway import PaymentsGatewayApi @@ -91,7 +100,6 @@ async def cancel_one_time_payment( user_id: UserID, wallet_id: WalletID, ) -> None: - payment = await repo.get_payment_transaction( payment_id=payment_id, user_id=user_id, wallet_id=wallet_id ) @@ -123,7 +131,6 @@ async def acknowledge_one_time_payment( payment_id: PaymentID, ack: AckPayment, ) -> PaymentsTransactionsDB: - return await repo_transactions.update_ack_payment_transaction( payment_id=payment_id, completion_state=( @@ -173,7 +180,7 @@ async def on_payment_completed( ) -async def init_payment_with_payment_method( +async def pay_with_payment_method( # noqa: PLR0913 gateway: PaymentsGatewayApi, repo_transactions: PaymentsTransactionsRepo, repo_methods: PaymentsMethodsRepo, @@ -188,14 +195,14 @@ async def init_payment_with_payment_method( user_name: str, user_email: EmailStr, comment: str | None = None, -) -> WalletPaymentInitiated: +) -> PaymentTransaction: initiated_at = arrow.utcnow().datetime acked = await repo_methods.get_payment_method( payment_method_id, user_id=user_id, wallet_id=wallet_id ) - payment_inited = await gateway.init_payment_with_payment_method( + ack: AckPaymentWithPaymentMethod = await gateway.pay_with_payment_method( acked.payment_method_id, payment=InitPayment( amount_dollars=amount_dollars, @@ -206,23 +213,41 @@ async def init_payment_with_payment_method( ), ) - payment_id = await repo_transactions.insert_init_payment_transaction( - payment_id=payment_inited.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=initiated_at, - ) + payment_id = ack.payment_id - return WalletPaymentInitiated( - payment_id=f"{payment_id}", - payment_form_url=None, + async for attempt in AsyncRetrying( + stop=stop_after_attempt(3), + retry=retry_if_exception_type(PaymentAlreadyExistsError), + reraise=True, + ): + with attempt: + payment_id = await repo_transactions.insert_init_payment_transaction( + ack.payment_id or f"{uuid.uuid4()}", + 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=initiated_at, + ) + + assert payment_id is not None # nosec + + transaction = await repo_transactions.update_ack_payment_transaction( + payment_id=payment_id, + completion_state=( + PaymentTransactionState.SUCCESS + if ack.success + else PaymentTransactionState.FAILED + ), + state_message=ack.message, + invoice_url=ack.invoice_url, ) + return transaction.to_api_model() + async def get_payments_page( repo: PaymentsTransactionsRepo, diff --git a/services/payments/src/simcore_service_payments/services/payments_gateway.py b/services/payments/src/simcore_service_payments/services/payments_gateway.py index 90d60dfe25d..c9d4174e054 100644 --- a/services/payments/src/simcore_service_payments/services/payments_gateway.py +++ b/services/payments/src/simcore_service_payments/services/payments_gateway.py @@ -5,19 +5,28 @@ """ - +import contextlib +import functools import logging +from collections.abc import Callable +from contextlib import suppress import httpx from fastapi import FastAPI from fastapi.encoders import jsonable_encoder -from httpx import URL +from httpx import URL, HTTPStatusError from models_library.api_schemas_webserver.wallets import PaymentID, PaymentMethodID +from pydantic import ValidationError +from pydantic.errors import PydanticErrorMixin from servicelib.fastapi.http_client import AppStateMixin, BaseHttpApi +from simcore_service_payments.models.schemas.acknowledgements import ( + AckPaymentWithPaymentMethod, +) from ..core.settings import ApplicationSettings from ..models.payments_gateway import ( BatchGetPaymentMethods, + ErrorModel, GetPaymentMethod, InitPayment, InitPaymentMethod, @@ -30,6 +39,43 @@ _logger = logging.getLogger(__name__) +class PaymentsGatewayError(PydanticErrorMixin, ValueError): + msg_template = "Payment-gateway got {status_code} for {operation_id}: {reason}" + + +def _parse_raw_or_none(text: str | None): + if text: + with suppress(ValidationError): + return ErrorModel.parse_raw(text) + return None + + +@contextlib.contextmanager +def _raise_if_error(operation_id: str): + try: + + yield + + except HTTPStatusError as err: + model = _parse_raw_or_none(err.response.text) + + raise PaymentsGatewayError( + operation_id=f"PaymentsGatewayApi.{operation_id}", + reason=model.message if model else f"{err}", + http_status_error=err, + model=model, + ) from err + + +def _handle_status_errors(coro: Callable): + @functools.wraps(coro) + async def _wrapper(self, *args, **kwargs): + with _raise_if_error(operation_id=coro.__name__): + return await coro(self, *args, **kwargs) + + return _wrapper + + class _GatewayApiAuth(httpx.Auth): def __init__(self, secret): self.token = secret @@ -46,6 +92,7 @@ class PaymentsGatewayApi(BaseHttpApi, AppStateMixin): # api: one-time-payment workflow # + @_handle_status_errors async def init_payment(self, payment: InitPayment) -> PaymentInitiated: response = await self.client.post( "/init", @@ -57,6 +104,7 @@ async def init_payment(self, payment: InitPayment) -> PaymentInitiated: def get_form_payment_url(self, id_: PaymentID) -> URL: return self.client.base_url.copy_with(path="/pay", params={"id": f"{id_}"}) + @_handle_status_errors async def cancel_payment( self, payment_initiated: PaymentInitiated ) -> PaymentCancelled: @@ -71,6 +119,7 @@ async def cancel_payment( # api: payment method workflows # + @_handle_status_errors async def init_payment_method( self, payment_method: InitPaymentMethod, @@ -89,6 +138,7 @@ def get_form_payment_method_url(self, id_: PaymentMethodID) -> URL: # CRUD + @_handle_status_errors async def get_many_payment_methods( self, ids_: list[PaymentMethodID] ) -> list[GetPaymentMethod]: @@ -99,24 +149,27 @@ async def get_many_payment_methods( response.raise_for_status() return PaymentMethodsBatch.parse_obj(response.json()).items + @_handle_status_errors async def get_payment_method(self, id_: PaymentMethodID) -> GetPaymentMethod: response = await self.client.get(f"/payment-methods/{id_}") response.raise_for_status() return GetPaymentMethod.parse_obj(response.json()) + @_handle_status_errors async def delete_payment_method(self, id_: PaymentMethodID) -> None: response = await self.client.delete(f"/payment-methods/{id_}") response.raise_for_status() - async def init_payment_with_payment_method( + @_handle_status_errors + async def pay_with_payment_method( self, id_: PaymentMethodID, payment: InitPayment - ) -> PaymentInitiated: + ) -> AckPaymentWithPaymentMethod: response = await self.client.post( f"/payment-methods/{id_}:pay", json=jsonable_encoder(payment), ) response.raise_for_status() - return PaymentInitiated.parse_obj(response.json()) + return AckPaymentWithPaymentMethod.parse_obj(response.json()) def setup_payments_gateway(app: FastAPI): diff --git a/services/payments/src/simcore_service_payments/services/postgres.py b/services/payments/src/simcore_service_payments/services/postgres.py index 59c7d0f950e..59d1c6f18c4 100644 --- a/services/payments/src/simcore_service_payments/services/postgres.py +++ b/services/payments/src/simcore_service_payments/services/postgres.py @@ -5,6 +5,12 @@ from ..core.settings import ApplicationSettings +def get_engine(app: FastAPI) -> AsyncEngine: + assert app.state.engine # nosec + engine: AsyncEngine = app.state.engine + return engine + + def setup_postgres(app: FastAPI): app.state.engine = None diff --git a/services/payments/tests/unit/conftest.py b/services/payments/tests/unit/conftest.py index b46bc3a4673..086f6052d98 100644 --- a/services/payments/tests/unit/conftest.py +++ b/services/payments/tests/unit/conftest.py @@ -37,9 +37,12 @@ PaymentMethodInitiated, PaymentMethodsBatch, ) +from simcore_service_payments.models.schemas.acknowledgements import ( + AckPaymentWithPaymentMethod, +) -@pytest.fixture +@pytest.fixture(scope="session") def is_pdb_enabled(request: pytest.FixtureRequest): """Returns true if tests are set to use interactive debugger, i.e. --pdb""" options = request.config.option @@ -126,15 +129,22 @@ def payments_clean_db(postgres_db: sa.engine.Engine) -> Iterator[None]: con.execute(payments_transactions.delete()) +# +MAX_TIME_FOR_APP_TO_STARTUP = 10 +MAX_TIME_FOR_APP_TO_SHUTDOWN = 10 + + @pytest.fixture -async def app(app_environment: EnvVarsDict) -> AsyncIterator[FastAPI]: - test_app = create_app() +async def app( + app_environment: EnvVarsDict, is_pdb_enabled: bool +) -> AsyncIterator[FastAPI]: + the_test_app = create_app() async with LifespanManager( - test_app, - startup_timeout=None, # for debugging - shutdown_timeout=10, + the_test_app, + startup_timeout=None if is_pdb_enabled else MAX_TIME_FOR_APP_TO_STARTUP, + shutdown_timeout=None if is_pdb_enabled else MAX_TIME_FOR_APP_TO_SHUTDOWN, ): - yield test_app + yield the_test_app # @@ -205,7 +215,7 @@ def _init(request: httpx.Request): pm_id = faker.uuid4() _payment_methods[pm_id] = PaymentMethodInfoTuple( init=InitPaymentMethod.parse_raw(request.content), - get=GetPaymentMethod(**random_payment_method_data(idr=pm_id)), + get=GetPaymentMethod(**random_payment_method_data(id=pm_id)), ) return httpx.Response( @@ -247,16 +257,25 @@ def _batch_get(request: httpx.Request): json=jsonable_encoder(PaymentMethodsBatch(items=items)), ) - def _init_payment(request: httpx.Request, pm_id: PaymentMethodID): + def _pay(request: httpx.Request, pm_id: PaymentMethodID): assert "*" not in request.headers["X-Init-Api-Secret"] assert InitPayment.parse_raw(request.content) is not None # checks _get(request, pm_id) + payment_id = faker.uuid4() return httpx.Response( status.HTTP_200_OK, - json=jsonable_encoder(PaymentInitiated(payment_id=faker.uuid4())), + json=jsonable_encoder( + AckPaymentWithPaymentMethod( + success=True, + message=f"Payment '{payment_id}' with payment-method '{pm_id}'", + invoice_url=faker.url(), + provider_payment_id="pi_123456ABCDEFG123456ABCDE", + payment_id=payment_id, + ) + ), ) # ------ @@ -283,8 +302,8 @@ def _init_payment(request: httpx.Request, pm_id: PaymentMethodID): mock_router.post( path__regex=r"/payment-methods/(?P[\w-]+):pay$", - name="init_payment_with_payment_method", - ).mock(side_effect=_init_payment) + name="pay_with_payment_method", + ).mock(side_effect=_pay) yield _mock @@ -305,7 +324,7 @@ def pytest_addoption(parser: pytest.Parser): ) -@pytest.fixture +@pytest.fixture(scope="session") def external_environment(request: pytest.FixtureRequest) -> EnvVarsDict: """ If a file under test folder prefixed with `.env-secret` is present, @@ -332,7 +351,6 @@ def mock_payments_gateway_service_or_none( mock_payments_methods_routes: Callable, external_environment: EnvVarsDict, ) -> MockRouter | None: - # EITHER tests against external payments-gateway if payments_gateway_url := external_environment.get("PAYMENTS_GATEWAY_URL"): print("🚨 EXTERNAL: these tests are running against", f"{payments_gateway_url=}") diff --git a/services/payments/tests/unit/test_rpc_payments_methods.py b/services/payments/tests/unit/test_rpc_payments_methods.py index 15ce2f05724..7cce4ae6f95 100644 --- a/services/payments/tests/unit/test_rpc_payments_methods.py +++ b/services/payments/tests/unit/test_rpc_payments_methods.py @@ -10,7 +10,7 @@ from fastapi import FastAPI from models_library.api_schemas_webserver.wallets import ( PaymentMethodInitiated, - WalletPaymentInitiated, + PaymentTransaction, ) from models_library.basic_types import IDStr from models_library.products import ProductName @@ -222,9 +222,9 @@ async def test_webserver_pay_with_payment_method_workflow( ack=AckPaymentMethod(success=True, message="Faked ACK"), ) - payment_inited = await rpc_client.request( + transaction = await rpc_client.request( PAYMENTS_RPC_NAMESPACE, - parse_obj_as(RPCMethodName, "init_payment_with_payment_method"), + parse_obj_as(RPCMethodName, "pay_with_payment_method"), payment_method_id=created.payment_method_id, amount_dollars=faker.pyint(), target_credits=faker.pyint(), @@ -237,14 +237,14 @@ async def test_webserver_pay_with_payment_method_workflow( comment="Payment with stored credit-card", ) - assert isinstance(payment_inited, WalletPaymentInitiated) - assert payment_inited.payment_id - assert payment_inited.payment_form_url is None + assert isinstance(transaction, PaymentTransaction) + assert transaction.payment_id + assert transaction.state == "SUCCESS" payment = await PaymentsTransactionsRepo(app.state.engine).get_payment_transaction( - payment_inited.payment_id, user_id=user_id, wallet_id=wallet_id + transaction.payment_id, user_id=user_id, wallet_id=wallet_id ) assert payment is not None - assert payment.payment_id == payment_inited.payment_id - assert payment.state == PaymentTransactionState.PENDING + assert payment.payment_id == transaction.payment_id + assert payment.state == PaymentTransactionState.SUCCESS assert payment.comment == "Payment with stored credit-card" diff --git a/services/payments/tests/unit/test_services_payments_gateway.py b/services/payments/tests/unit/test_services_payments_gateway.py index f29008257f9..8a0b34f9350 100644 --- a/services/payments/tests/unit/test_services_payments_gateway.py +++ b/services/payments/tests/unit/test_services_payments_gateway.py @@ -4,7 +4,6 @@ # pylint: disable=too-many-arguments -import httpx import pytest from faker import Faker from fastapi import FastAPI, status @@ -17,6 +16,7 @@ ) from simcore_service_payments.services.payments_gateway import ( PaymentsGatewayApi, + PaymentsGatewayError, setup_payments_gateway, ) @@ -92,7 +92,6 @@ async def test_one_time_payment_workflow( mock_payments_gateway_service_or_none: MockRouter | None, amount_dollars: float, ): - payment_gateway_api = PaymentsGatewayApi.get_from_app_state(app) assert payment_gateway_api @@ -125,13 +124,13 @@ async def test_one_time_payment_workflow( assert mock_payments_gateway_service_or_none.routes["cancel_payment"].called +@pytest.mark.can_run_against_external() async def test_payment_methods_workflow( app: FastAPI, faker: Faker, mock_payments_gateway_service_or_none: MockRouter | None, amount_dollars: float, ): - payments_gateway_api: PaymentsGatewayApi = PaymentsGatewayApi.get_from_app_state( app ) @@ -161,7 +160,7 @@ async def test_payment_methods_workflow( got_payment_method = await payments_gateway_api.get_payment_method( payment_method_id ) - assert got_payment_method.idr == payment_method_id + assert got_payment_method.id == payment_method_id print(got_payment_method.json(indent=2)) # list payment-methods @@ -171,8 +170,7 @@ async def test_payment_methods_workflow( assert len(items) == 1 assert items[0] == got_payment_method - # init payments with payment-method (needs to be ACK to complete) - payment_initiated = await payments_gateway_api.init_payment_with_payment_method( + payment_with_payment_method = await payments_gateway_api.pay_with_payment_method( id_=payment_method_id, payment=InitPayment( amount_dollars=amount_dollars, @@ -182,23 +180,20 @@ async def test_payment_methods_workflow( wallet_name=faker.word(), ), ) - - # cancel payment - payment_canceled = await payments_gateway_api.cancel_payment(payment_initiated) - assert payment_canceled is not None + assert payment_with_payment_method.success # delete payment-method await payments_gateway_api.delete_payment_method(payment_method_id) - with pytest.raises(httpx.HTTPStatusError) as err_info: + with pytest.raises(PaymentsGatewayError) as err_info: await payments_gateway_api.get_payment_method(payment_method_id) - http_status_error = err_info.value + assert err_info.value.operation_id == "PaymentsGatewayApi.get_payment_method" + + http_status_error = err_info.value.http_status_error assert http_status_error.response.status_code == status.HTTP_404_NOT_FOUND if mock_payments_gateway_service_or_none: - assert mock_payments_gateway_service_or_none.routes["cancel_payment"].called - # all defined payment-methods for route in mock_payments_gateway_service_or_none.routes: if route.name and "payment_method" in route.name: diff --git a/services/static-webserver/client/source/class/osparc/desktop/paymentMethods/PaymentMethodDetails.js b/services/static-webserver/client/source/class/osparc/desktop/paymentMethods/PaymentMethodDetails.js index 80fbd659e38..49dbab989ed 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/paymentMethods/PaymentMethodDetails.js +++ b/services/static-webserver/client/source/class/osparc/desktop/paymentMethods/PaymentMethodDetails.js @@ -50,10 +50,7 @@ qx.Class.define("osparc.desktop.paymentMethods.PaymentMethodDetails", { [this.tr("Holder name"), paymentMethodData["cardHolderName"]], [this.tr("Type"), paymentMethodData["cardType"]], [this.tr("Number"), paymentMethodData["cardNumberMasked"]], - [this.tr("Expiration date"), paymentMethodData["expirationMonth"] + "/" + paymentMethodData["expirationYear"]], - [this.tr("Address"), paymentMethodData["streetAddress"]], - [this.tr("ZIP code"), paymentMethodData["zipcode"]], - [this.tr("Country"), paymentMethodData["country"]] + [this.tr("Expiration date"), paymentMethodData["expirationMonth"] + "/" + paymentMethodData["expirationYear"]] ].forEach((pair, idx) => { this._add(new qx.ui.basic.Label(pair[0]).set({ font: "text-14" diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index d84d9c55f10..15d76e2571f 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -4312,8 +4312,8 @@ paths: post: tags: - wallets - summary: Init Payment With Payment Method - operationId: init_payment_with_payment_method + summary: Pay With Payment Method + operationId: pay_with_payment_method parameters: - required: true schema: @@ -4339,7 +4339,7 @@ paths: required: true responses: '202': - description: Payment initialized + description: Pay with payment-method content: application/json: schema: @@ -5256,7 +5256,11 @@ components: properties: priceDollars: title: Pricedollars + exclusiveMaximum: true + exclusiveMinimum: true type: number + maximum: 1000000.0 + minimum: 0.0 comment: title: Comment maxLength: 100 @@ -7545,14 +7549,6 @@ components: required: - idr - walletId - - cardHolderName - - cardNumberMasked - - cardType - - expirationMonth - - expirationYear - - streetAddress - - zipcode - - country - created type: object properties: @@ -7581,15 +7577,6 @@ components: expirationYear: title: Expirationyear type: integer - streetAddress: - title: Streetaddress - type: string - zipcode: - title: Zipcode - type: string - country: - title: Country - type: string created: title: Created type: string 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 354f87ae67b..c4b2b56309c 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 @@ -40,15 +40,12 @@ def _generate_fake_data(fake: Faker): return { - "idr": fake.uuid4(), + "id": fake.uuid4(), "card_holder_name": fake.name(), "card_number_masked": f"**** **** **** {fake.credit_card_number()[:4]}", "card_type": fake.credit_card_provider(), "expiration_month": fake.random_int(min=1, max=12), "expiration_year": fake.future_date().year, - "street_address": fake.street_address(), - "zipcode": fake.zipcode(), - "country": fake.country(), } diff --git a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py index 9a49028cbe8..00ce812f7fa 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py @@ -135,7 +135,6 @@ async def init_creation_of_wallet_payment( user_id: UserID, wallet_id: WalletID, comment: str | None, - payment_method_id: PaymentMethodID | None = None, ) -> WalletPaymentInitiated: """ @@ -157,38 +156,22 @@ async def init_creation_of_wallet_payment( user = await get_user_name_and_email(app, user_id=user_id) settings: PaymentsSettings = get_plugin_settings(app) payment_inited: WalletPaymentInitiated - if payment_method_id is None: - if settings.PAYMENTS_FAKE_COMPLETION: - payment_inited = await _fake_init_payment( - app, - price_dollars, - osparc_credits, - product_name, - wallet_id, - user_id, - user.email, - comment, - ) - else: - # call to payment-service - assert not settings.PAYMENTS_FAKE_COMPLETION # nosec - payment_inited = await _rpc.init_payment( - app, - 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, - comment=comment, - ) + if settings.PAYMENTS_FAKE_COMPLETION: + payment_inited = await _fake_init_payment( + app, + price_dollars, + osparc_credits, + product_name, + wallet_id, + user_id, + user.email, + comment, + ) else: - assert payment_method_id is not None # nosec - payment_inited = await _rpc.init_payment_with_payment_method( + # call to payment-service + assert not settings.PAYMENTS_FAKE_COMPLETION # nosec + payment_inited = await _rpc.init_payment( app, - payment_method_id=payment_method_id, amount_dollars=price_dollars, target_credits=osparc_credits, product_name=product_name, @@ -210,6 +193,7 @@ async def _ack_creation_of_wallet_payment( completion_state: PaymentTransactionState, message: str | None = None, invoice_url: HttpUrl | None = None, + notify_enabled: bool = True, ) -> PaymentTransaction: # # NOTE: implements endpoint in payment service hit by the gateway @@ -231,7 +215,10 @@ async def _ack_creation_of_wallet_payment( payment = _to_api_model(transaction) # notifying front-end via web-sockets - await notify_payment_completed(app, user_id=transaction.user_id, payment=payment) + if notify_enabled: + await notify_payment_completed( + app, user_id=transaction.user_id, payment=payment + ) if completion_state == PaymentTransactionState.SUCCESS: # notifying RUT @@ -285,6 +272,100 @@ async def cancel_payment_to_wallet( ) +async def _fake_pay_with_payment_method( # noqa: PLR0913 pylint: disable=too-many-arguments + app, + amount_dollars, + target_credits, + product_name, + wallet_id, + wallet_name, + user_id, + user_name, + user_email, + payment_method_id: PaymentMethodID, + comment, +) -> PaymentTransaction: + + assert user_name # nosec + assert wallet_name # nosec + + inited = await _fake_init_payment( + app, + amount_dollars, + target_credits, + product_name, + wallet_id, + user_id, + user_email, + comment, + ) + return await _ack_creation_of_wallet_payment( + app, + payment_id=inited.payment_id, + completion_state=PaymentTransactionState.SUCCESS, + message=f"Fake payment completed with payment-id = {payment_method_id}", + invoice_url=f"https://fake-invoice.com/?id={inited.payment_id}", # type: ignore + notify_enabled=False, + ) + + +async def pay_with_payment_method( + app: web.Application, + *, + price_dollars: Decimal, + osparc_credits: Decimal, + product_name: str, + user_id: UserID, + wallet_id: WalletID, + payment_method_id: PaymentMethodID, + comment: str | None, +) -> PaymentTransaction: + + # wallet: check permissions + await raise_for_wallet_payments_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 + + # user info + user = await get_user_name_and_email(app, user_id=user_id) + + settings: PaymentsSettings = get_plugin_settings(app) + if settings.PAYMENTS_FAKE_COMPLETION: + return await _fake_pay_with_payment_method( + app, + payment_method_id=payment_method_id, + 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, + comment=comment, + ) + + assert not settings.PAYMENTS_FAKE_COMPLETION # nosec + + return await _rpc.pay_with_payment_method( + app, + payment_method_id=payment_method_id, + 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, + comment=comment, + ) + + async def list_user_payments_page( app: web.Application, product_name: str, diff --git a/services/web/server/src/simcore_service_webserver/payments/_rpc.py b/services/web/server/src/simcore_service_webserver/payments/_rpc.py index bd4a2ec74b9..e5c0d2680a3 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_rpc.py +++ b/services/web/server/src/simcore_service_webserver/payments/_rpc.py @@ -12,6 +12,7 @@ PaymentMethodGet, PaymentMethodID, PaymentMethodInitiated, + PaymentTransaction, WalletPaymentInitiated, ) from models_library.basic_types import IDStr @@ -211,7 +212,7 @@ async def delete_payment_method( @log_decorator(_logger, level=logging.DEBUG) -async def init_payment_with_payment_method( # noqa: PLR0913 # pylint: disable=too-many-arguments +async def pay_with_payment_method( # noqa: PLR0913 # pylint: disable=too-many-arguments app: web.Application, *, payment_method_id: PaymentMethodID, @@ -224,12 +225,13 @@ async def init_payment_with_payment_method( # noqa: PLR0913 # pylint: disable=t user_name: str, user_email: EmailStr, comment: str | None = None, -) -> WalletPaymentInitiated: +) -> PaymentTransaction: + rpc_client = app[_APP_PAYMENTS_RPC_CLIENT_KEY] result = await rpc_client.request( PAYMENTS_RPC_NAMESPACE, - parse_obj_as(RPCMethodName, "init_payment_with_payment_method"), + parse_obj_as(RPCMethodName, "pay_with_payment_method"), payment_method_id=payment_method_id, amount_dollars=amount_dollars, target_credits=target_credits, @@ -241,5 +243,6 @@ async def init_payment_with_payment_method( # noqa: PLR0913 # pylint: disable=t user_email=user_email, comment=comment, ) - assert isinstance(result, WalletPaymentInitiated) # nosec + + assert isinstance(result, PaymentTransaction) # nosec return result 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 4dbdd9d4a0b..171ebd5e574 100644 --- a/services/web/server/src/simcore_service_webserver/payments/api.py +++ b/services/web/server/src/simcore_service_webserver/payments/api.py @@ -13,18 +13,22 @@ cancel_payment_to_wallet, init_creation_of_wallet_payment, list_user_payments_page, + pay_with_payment_method, ) +from ._socketio import notify_payment_completed __all__: tuple[str, ...] = ( "cancel_creation_of_wallet_payment_method", "cancel_payment_to_wallet", - "init_creation_of_wallet_payment", "delete_wallet_payment_method", - "list_user_payments_page", "get_wallet_payment_autorecharge", "get_wallet_payment_method", "init_creation_of_wallet_payment_method", + "init_creation_of_wallet_payment", + "list_user_payments_page", "list_wallet_payment_methods", + "notify_payment_completed", + "pay_with_payment_method", "replace_wallet_payment_autorecharge", ) # nopycln: file 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 38a327a3a78..c4cac0680f2 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 @@ -11,8 +11,11 @@ ReplaceWalletAutoRecharge, WalletPaymentInitiated, ) +from models_library.basic_types import AmountDecimal, NonNegativeDecimal from models_library.rest_pagination import Page, PageQueryParameters from models_library.rest_pagination_utils import paginate_data +from pydantic import parse_obj_as +from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, @@ -20,6 +23,7 @@ ) from servicelib.logging_utils import get_log_record_extra, log_context from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from servicelib.utils import fire_and_forget_task from .._meta import API_VTAG as VTAG from ..login.decorators import login_required @@ -33,6 +37,8 @@ init_creation_of_wallet_payment_method, list_user_payments_page, list_wallet_payment_methods, + notify_payment_completed, + pay_with_payment_method, replace_wallet_payment_autorecharge, ) from ..products.api import get_current_product_credit_price @@ -48,30 +54,16 @@ _logger = logging.getLogger(__name__) -async def _init_creation_of_payments( - request: web.Request, - user_id, - product_name, - wallet_id, - payment_method_id, - init: CreateWalletPayment, -) -> WalletPaymentInitiated: +async def _eval_total_credits_or_raise( + request: web.Request, amount_dollars: AmountDecimal +) -> NonNegativeDecimal: # Conversion usd_per_credit = await get_current_product_credit_price(request) if not usd_per_credit: # '0 or None' should raise raise web.HTTPConflict(reason=MSG_PRICE_NOT_DEFINED_ERROR) - return await init_creation_of_wallet_payment( - request.app, - user_id=user_id, - product_name=product_name, - wallet_id=wallet_id, - osparc_credits=init.price_dollars / usd_per_credit, - comment=init.comment, - price_dollars=init.price_dollars, - payment_method_id=payment_method_id, - ) + return parse_obj_as(NonNegativeDecimal, amount_dollars / usd_per_credit) routes = web.RouteTableDef() @@ -100,13 +92,18 @@ async def _create_payment(request: web.Request): extra=get_log_record_extra(user_id=req_ctx.user_id), ): - payment: WalletPaymentInitiated = await _init_creation_of_payments( - request, + osparc_credits = await _eval_total_credits_or_raise( + request, body_params.price_dollars + ) + + payment: WalletPaymentInitiated = await init_creation_of_wallet_payment( + request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, wallet_id=wallet_id, - payment_method_id=None, - init=body_params, + osparc_credits=osparc_credits, + comment=body_params.comment, + price_dollars=body_params.price_dollars, ) return envelope_json_response(payment, web.HTTPCreated) @@ -317,15 +314,12 @@ async def _delete_payment_method(request: web.Request): @routes.post( f"/{VTAG}/wallets/{{wallet_id}}/payments-methods/{{payment_method_id}}:pay", - name="init_payment_with_payment_method", + name="pay_with_payment_method", ) @login_required @permission_required("wallets.*") @handle_wallets_exceptions -async def _init_payment_with_payment_method(request: web.Request): - """Triggers the creation of a new payment method. - Note that creating a payment-method follows the init-prompt-ack flow - """ +async def _pay_with_payment_method(request: web.Request): req_ctx = WalletsRequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(PaymentMethodsPathParams, request) body_params = await parse_request_body_as(CreateWalletPayment, request) @@ -341,16 +335,40 @@ async def _init_payment_with_payment_method(request: web.Request): extra=get_log_record_extra(user_id=req_ctx.user_id), ): - payment: WalletPaymentInitiated = await _init_creation_of_payments( - request, + osparc_credits = await _eval_total_credits_or_raise( + request, body_params.price_dollars + ) + + payment: PaymentTransaction = await pay_with_payment_method( + request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, wallet_id=wallet_id, payment_method_id=path_params.payment_method_id, - init=body_params, + osparc_credits=osparc_credits, + comment=body_params.comment, + price_dollars=body_params.price_dollars, ) - return envelope_json_response(payment, web.HTTPAccepted) + # NOTE: Due to the design change in https://github.com/ITISFoundation/osparc-simcore/pull/5017 + # we decided not to change the return value to avoid changing the front-end logic + # instead we emulate a init-prompt-ack workflow by firing a background task that acks payment + + fire_and_forget_task( + notify_payment_completed( + request.app, user_id=req_ctx.user_id, payment=payment + ), + task_suffix_name=f"{__name__}._pay_with_payment_method", + fire_and_forget_tasks_collection=request.app[APP_FIRE_AND_FORGET_TASKS_KEY], + ) + + return envelope_json_response( + WalletPaymentInitiated( + payment_id=payment.payment_id, + payment_form_url=None, + ), + web.HTTPAccepted, + ) # 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 e94b6570b62..51df97d8567 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 @@ -20,6 +20,7 @@ PaymentMethodGet, PaymentMethodID, PaymentMethodInitiated, + PaymentTransaction, WalletGet, ) from models_library.basic_types import IDStr @@ -41,6 +42,7 @@ from simcore_service_webserver.payments._onetime_api import ( _fake_cancel_payment, _fake_init_payment, + _fake_pay_with_payment_method, ) from simcore_service_webserver.payments.settings import ( PaymentsSettings, @@ -200,24 +202,27 @@ async def _pay( user_name: str, user_email: EmailStr, comment: str | None = None, - ): + ) -> PaymentTransaction: + assert await _get( app, payment_method_id=payment_method_id, user_id=user_id, wallet_id=wallet_id, ) - return await _init( + + return await _fake_pay_with_payment_method( app, - 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, + amount_dollars, + target_credits, + product_name, + wallet_id, + wallet_name, + user_id, + user_name, + user_email, + payment_method_id, + comment, ) return { @@ -256,8 +261,8 @@ async def _pay( autospec=True, side_effect=_del, ), - "init_payment_with_payment_method": mocker.patch( - "simcore_service_webserver.payments._onetime_api._rpc.init_payment_with_payment_method", + "pay_with_payment_method": mocker.patch( + "simcore_service_webserver.payments._onetime_api._rpc.pay_with_payment_method", autospec=True, side_effect=_pay, ), 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 9b1fe418560..c18ce0973dc 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 @@ -26,15 +26,9 @@ from pytest_mock import MockerFixture from pytest_simcore.helpers.utils_assert import assert_status from simcore_postgres_database.models.payments_methods import InitPromptAckFlowState -from simcore_postgres_database.models.payments_transactions import ( - PaymentTransactionState, -) from simcore_service_webserver.payments._methods_api import ( _ack_creation_of_wallet_payment_method, ) -from simcore_service_webserver.payments._onetime_api import ( - _ack_creation_of_wallet_payment, -) from simcore_service_webserver.payments.settings import PaymentsSettings from simcore_service_webserver.payments.settings import ( get_plugin_settings as get_payments_plugin_settings, @@ -354,7 +348,7 @@ async def test_one_time_payment_with_payment_method( ) assert ( - client.app.router["init_payment_with_payment_method"] + client.app.router["pay_with_payment_method"] .url_for( wallet_id=f"{logged_user_wallet.wallet_id}", payment_method_id=wallet_payment_method_id, @@ -373,21 +367,11 @@ async def test_one_time_payment_with_payment_method( data, error = await assert_status(response, web.HTTPAccepted) assert error is None payment = WalletPaymentInitiated.parse_obj(data) - assert mock_rpc_payments_service_api["init_payment_with_payment_method"].called + assert mock_rpc_payments_service_api["pay_with_payment_method"].called assert payment.payment_id - assert payment.payment_form_url - 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 payment.payment_form_url is None - # Complete - await _ack_creation_of_wallet_payment( - client.app, - payment_id=payment.payment_id, - completion_state=PaymentTransactionState.SUCCESS, - invoice_url=faker.url(), - ) # check notification to RUT (fake) assert mock_rut_add_credits_to_wallet.called mock_rut_add_credits_to_wallet.assert_called_once()