diff --git a/.env-devel b/.env-devel index 567674ee153..18a56602c5e 100644 --- a/.env-devel +++ b/.env-devel @@ -71,7 +71,7 @@ PAYMENTS_FAKE_COMPLETION_DELAY_SEC=10 PAYMENTS_FAKE_COMPLETION=0 # NOTE: this can be 1 ONLY if WEBSERVER_DEV_FEATURES_ENABLED=1 PAYMENTS_GATEWAY_API_KEY=replace-with-api-key PAYMENTS_GATEWAY_API_SECRET=replace-with-api-secret -PAYMENTS_GATEWAY_URL=http://fake-payment-gateway.com +PAYMENTS_GATEWAY_URL=https://fake-payment-gateway.com PAYMENTS_HOST=payments PAYMENTS_LOGLEVEL=INFO PAYMENTS_PASSWORD=adminadmin diff --git a/api/specs/web-server/_wallets.py b/api/specs/web-server/_wallets.py index 6acb7e0f26b..6937661f9fe 100644 --- a/api/specs/web-server/_wallets.py +++ b/api/specs/web-server/_wallets.py @@ -14,6 +14,9 @@ CreateWalletBodyParams, CreateWalletPayment, PaymentID, + PaymentMethodGet, + PaymentMethodID, + PaymentMethodInit, PaymentTransaction, PutWalletBodyParams, WalletGet, @@ -90,6 +93,55 @@ async def cancel_payment(wallet_id: WalletID, payment_id: PaymentID): ... +### Wallets payment-methods + + +@router.post( + "/wallets/{wallet_id}/payments-methods:init", + response_model=Envelope[PaymentMethodInit], +) +async def init_creation_of_payment_method(wallet_id: WalletID): + ... + + +@router.post( + "/wallets/{wallet_id}/payments-methods/{payment_method_id}:cancel", + status_code=status.HTTP_204_NO_CONTENT, + response_description="Successfully cancelled", +) +async def cancel_creation_of_payment_method( + wallet_id: WalletID, payment_method_id: PaymentMethodID +): + ... + + +@router.get( + "/wallets/{wallet_id}/payments-methods", + response_model=Envelope[list[PaymentMethodGet]], +) +async def list_payments_methods(wallet_id: WalletID): + """Lists all payments method associated to `wallet_id`""" + + +@router.get( + "/wallets/{wallet_id}/payments-methods/{payment_method_id}", + response_model=Envelope[PaymentMethodGet], +) +async def get_payment_method(wallet_id: WalletID, payment_method_id: PaymentMethodID): + ... + + +@router.delete( + "/wallets/{wallet_id}/payments-methods/{payment_method_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_description="Successfully deleted", +) +async def delete_payment_method( + wallet_id: WalletID, payment_method_id: PaymentMethodID +): + ... + + ### Wallets groups diff --git a/docs/init-prompt-ack_flow.drawio.png b/docs/init-prompt-ack_flow.drawio.png new file mode 100644 index 00000000000..a0174691e3e Binary files /dev/null and b/docs/init-prompt-ack_flow.drawio.png differ 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 97587763985..9eafa4f27e0 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 @@ -1,8 +1,7 @@ from datetime import datetime from decimal import Decimal -from typing import Literal, TypeAlias +from typing import Any, ClassVar, Literal, TypeAlias -from models_library.utils.pydantic_tools_extension import FieldNotRequired from pydantic import Field, HttpUrl from ..basic_types import IDStr @@ -79,3 +78,58 @@ class PaymentTransaction(OutputSchema): ) state_message: str = FieldNotRequired() invoice_url: HttpUrl = FieldNotRequired() + + +PaymentMethodID: TypeAlias = IDStr + + +class PaymentMethodInit(OutputSchema): + wallet_id: WalletID + payment_method_id: PaymentMethodID + payment_method_form_url: HttpUrl = Field( + ..., description="Link to external site that holds the payment submission form" + ) + + class Config(OutputSchema.Config): + schema_extra: ClassVar[dict[str, Any]] = { + "examples": [ + { + "wallet_id": 1, + "payment_method_id": "pm_0987654321", + "payment_method_form_url": "https://example.com/payment-method/form", + } + ] + } + + +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 + created: datetime + + class Config(OutputSchema.Config): + schema_extra: ClassVar[dict[str, Any]] = { + "examples": [ + { + "idr": "pm_1234567890", + "wallet_id": 1, + "card_holder_name": "John Doe", + "card_number_masked": "**** **** **** 1234", + "card_type": "Visa", + "expiration_month": 10, + "expiration_year": 2025, + "street_address": "123 Main St", + "zipcode": "12345", + "country": "United States", + "created": "2023-09-13T15:30:00Z", + }, + ], + } diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/624a029738b8_new_payments_method_table.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/624a029738b8_new_payments_method_table.py new file mode 100644 index 00000000000..fb93fc7755f --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/624a029738b8_new_payments_method_table.py @@ -0,0 +1,114 @@ +"""New payments_method table + +Revision ID: 624a029738b8 +Revises: e7b3d381efe4 +Create Date: 2023-09-13 15:05:41.094403+00:00 + +""" +from typing import Final + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "624a029738b8" +down_revision = "e7b3d381efe4" +branch_labels = None +depends_on = None + + +# auto-update modified +# TRIGGERS ------------------------ +_TABLE_NAME: Final[str] = "payments_methods" +_TRIGGER_NAME: Final[str] = "trigger_auto_update" # NOTE: scoped on table +_PROCEDURE_NAME: Final[ + str +] = f"{_TABLE_NAME}_auto_update_modified()" # NOTE: scoped on database +modified_timestamp_trigger = sa.DDL( + f""" +DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME}; +CREATE TRIGGER {_TRIGGER_NAME} +BEFORE INSERT OR UPDATE ON {_TABLE_NAME} +FOR EACH ROW EXECUTE PROCEDURE {_PROCEDURE_NAME}; + """ +) + +# PROCEDURES ------------------------ +update_modified_timestamp_procedure = sa.DDL( + f""" +CREATE OR REPLACE FUNCTION {_PROCEDURE_NAME} +RETURNS TRIGGER AS $$ +BEGIN + NEW.modified := current_timestamp; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + """ +) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "payments_methods", + sa.Column("payment_method_id", sa.String(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("wallet_id", sa.BigInteger(), nullable=False), + sa.Column("initiated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "state", + sa.Enum( + "PENDING", + "SUCCESS", + "FAILED", + "CANCELED", + name="initpromptackflowstate", + ), + nullable=False, + server_default="PENDING", + ), + sa.Column("state_message", sa.Text(), nullable=True), + sa.Column( + "created", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "modified", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("payment_method_id"), + ) + op.create_index( + op.f("ix_payments_methods_user_id"), + "payments_methods", + ["user_id"], + unique=False, + ) + op.create_index( + op.f("ix_payments_methods_wallet_id"), + "payments_methods", + ["wallet_id"], + unique=False, + ) + # ### end Alembic commands ### + + # custom + op.execute(update_modified_timestamp_procedure) + op.execute(modified_timestamp_trigger) + + +def downgrade(): + # custom + op.execute(f"DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME};") + op.execute(f"DROP FUNCTION {_PROCEDURE_NAME};") + + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_payments_methods_wallet_id"), table_name="payments_methods") + op.drop_index(op.f("ix_payments_methods_user_id"), table_name="payments_methods") + op.drop_table("payments_methods") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/payments_methods.py b/packages/postgres-database/src/simcore_postgres_database/models/payments_methods.py new file mode 100644 index 00000000000..3aabc3f992c --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/payments_methods.py @@ -0,0 +1,89 @@ +import enum + +import sqlalchemy as sa + +from ._common import ( + column_created_datetime, + column_modified_datetime, + register_modified_datetime_auto_update_trigger, +) +from .base import metadata + + +@enum.unique +class InitPromptAckFlowState(str, enum.Enum): + PENDING = "PENDING" # initiated + SUCCESS = "SUCCESS" # completed (ack) with success + FAILED = "FAILED" # failed + CANCELED = "CANCELED" # explicitly aborted by user + + +# +# NOTE: +# - This table was designed to work in an isolated database. For that reason +# we do not use ForeignKeys to establish relations with other tables (e.g. user_id). +# - Payment methods are owned by a user and associated to a wallet. When the same CC is added +# in the framework by different users, the gateway will produce different payment_method_id for each +# of them (VERIFY assumption) +# - A payment method is unique, i.e. only one per wallet and user. For the moment, we intentially avoid the +# possibility of associating a payment method to more than one wallet to avoid complexity +# +payments_methods = sa.Table( + "payments_methods", + metadata, + sa.Column( + "payment_method_id", + sa.String, + nullable=False, + primary_key=True, + doc="Unique identifier of the payment method provided by payment gateway", + ), + sa.Column( + "user_id", + sa.BigInteger, + nullable=False, + doc="Unique identifier of the user", + index=True, + ), + sa.Column( + "wallet_id", + sa.BigInteger, + nullable=False, + doc="Unique identifier to the wallet owned by the user", + index=True, + ), + # + # States of Init-Prompt-Ack flow + # + sa.Column( + "initiated_at", + sa.DateTime(timezone=True), + nullable=False, + doc="Timestamps init step of the flow", + ), + sa.Column( + "completed_at", + sa.DateTime(timezone=True), + nullable=True, + doc="Timestamps ack step of the flow", + ), + sa.Column( + "state", + sa.Enum(InitPromptAckFlowState), + nullable=False, + default=InitPromptAckFlowState.PENDING, + doc="Current state of this row in the flow ", + ), + sa.Column( + "state_message", + sa.Text, + nullable=True, + doc="State message to with details on the state e.g. failure messages", + ), + # time-stamps + column_created_datetime(timezone=True), + column_modified_datetime(timezone=True), +) + + +register_modified_datetime_auto_update_trigger(payments_methods) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/payments_transactions.py b/packages/postgres-database/src/simcore_postgres_database/models/payments_transactions.py index 163e8207d02..71776c20f11 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/payments_transactions.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/payments_transactions.py @@ -18,6 +18,11 @@ class PaymentTransactionState(str, enum.Enum): CANCELED = "CANCELED" # payment explicitly aborted by user +# +# NOTE: +# - This table was designed to work in an isolated database. For that reason +# we do not use ForeignKeys to establish relations with other tables (e.g. user_id, product_name, etc). +# payments_transactions = sa.Table( "payments_transactions", metadata, diff --git a/packages/postgres-database/tests/test_models_payments_methods.py b/packages/postgres-database/tests/test_models_payments_methods.py new file mode 100644 index 00000000000..554dd8e519a --- /dev/null +++ b/packages/postgres-database/tests/test_models_payments_methods.py @@ -0,0 +1,96 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +import datetime +from typing import Any + +import pytest +import sqlalchemy as sa +from aiopg.sa.connection import SAConnection +from aiopg.sa.result import RowProxy +from faker import Faker +from pytest_simcore.helpers.rawdata_fakers import FAKE +from simcore_postgres_database.errors import UniqueViolation +from simcore_postgres_database.models.payments_methods import ( + InitPromptAckFlowState, + payments_methods, +) + + +def _utcnow() -> datetime.datetime: + return datetime.datetime.now(tz=datetime.timezone.utc) + + +def _random_payment_method( + **overrides, +) -> dict[str, Any]: + data = { + "payment_method_id": FAKE.uuid4(), + "user_id": FAKE.pyint(), + "wallet_id": FAKE.pyint(), + "initiated_at": _utcnow(), + } + # state is not added on purpose + assert set(data.keys()).issubset({c.name for c in payments_methods.columns}) + + data.update(overrides) + return data + + +@pytest.fixture +def payment_method_id(faker: Faker) -> str: + return "5495BF38-4A98-430C-A028-19E4585ADFC7" + + +async def test_create_payment_method( + connection: SAConnection, + payment_method_id: str, +): + init_values = _random_payment_method(payment_method_id=payment_method_id) + await connection.execute(payments_methods.insert().values(**init_values)) + + # unique payment_method_id + with pytest.raises(UniqueViolation) as err_info: + await connection.execute(payments_methods.insert().values(**init_values)) + error = err_info.value + assert "payment_method_id" in f"{error}" + + # Create payment-method for another entity + for n in range(2): + # every user has its own wallet + wallet_id = init_values["wallet_id"] + n + user_id = init_values["user_id"] + n + for _ in range(3): # payments to wallet_id by user_id + await connection.execute( + payments_methods.insert().values( + **_random_payment_method(wallet_id=wallet_id, user_id=user_id) + ) + ) + + # list payment methods in wallet_id (user_id) + result = await connection.execute( + sa.select(payments_methods).where( + (payments_methods.c.wallet_id == init_values["wallet_id"]) + & ( + payments_methods.c.user_id == init_values["user_id"] + ) # ensures ownership + & (payments_methods.c.state == InitPromptAckFlowState.PENDING) + ) + ) + rows = await result.fetchall() + assert rows + assert len(rows) == 1 + 3 + + # get payment-method wallet_id / payment_method_id + result = await connection.execute( + sa.select(payments_methods).where( + (payments_methods.c.payment_method_id == init_values["payment_method_id"]) + & (payments_methods.c.wallet_id == init_values["wallet_id"]) + ) + ) + row: RowProxy | None = await result.fetchone() + assert row is not None + + # a payment-method added by a user and associated to a wallet diff --git a/services/docker-compose.yml b/services/docker-compose.yml index 883fddf3a3c..41f2c76ce0a 100644 --- a/services/docker-compose.yml +++ b/services/docker-compose.yml @@ -389,7 +389,7 @@ services: # WEBSERVER_PAYMENTS PAYMENTS_FAKE_COMPLETION_DELAY_SEC: ${PAYMENTS_FAKE_COMPLETION_DELAY_SEC} PAYMENTS_FAKE_COMPLETION: ${PAYMENTS_FAKE_COMPLETION} - PAYMENTS_GATEWAY_URL: ${PAYMENTS_GATEWAY_URL} + PAYMENTS_FAKE_GATEWAY_URL: ${PAYMENTS_GATEWAY_URL} PAYMENTS_HOST: ${PAYMENTS_HOST} PAYMENTS_PASSWORD: ${PAYMENTS_PASSWORD} PAYMENTS_PORT: ${PAYMENTS_PORT} 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 1ffcb72b2f9..81030b241fe 100644 --- a/services/payments/src/simcore_service_payments/models/payments_gateway.py +++ b/services/payments/src/simcore_service_payments/models/payments_gateway.py @@ -44,6 +44,9 @@ class GetPaymentMethod(BaseModel): card_type: str expiration_month: int expiration_year: int + street_address: str + zipcode: str + country: str created: datetime 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 9efe17ab766..ac36986a623 100644 --- a/services/payments/src/simcore_service_payments/services/payments_gateway.py +++ b/services/payments/src/simcore_service_payments/services/payments_gateway.py @@ -135,6 +135,7 @@ def setup(cls, app: FastAPI): app_settings: ApplicationSettings = app.state.settings app.state.payment_gateway_api = api = cls.create(app_settings) + assert cls.get_from_state(app) == api # nosec async def on_startup(): await api.start() diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 697f087f376..ae6dd4e2032 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.28.0 +0.29.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index d896c31b0ef..44cbd81cc2b 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.28.0 +current_version = 0.29.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False 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 bc46db232b9..515f6527371 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 @@ -3,7 +3,7 @@ info: title: simcore-service-webserver description: ' Main service with an interface (http-API & websockets) to the web front-end' - version: 0.28.0 + version: 0.29.0 servers: - url: '' description: webserver @@ -3871,6 +3871,129 @@ paths: responses: '204': description: Successful Response + /v0/wallets/{wallet_id}/payments-methods:init: + post: + tags: + - wallets + summary: Init Creation Of Payment Method + operationId: init_creation_of_payment_method + parameters: + - required: true + schema: + title: Wallet Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: wallet_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_CreatePaymentMethodInitiated_' + /v0/wallets/{wallet_id}/payments-methods/{payment_method_id}:cancel: + post: + tags: + - wallets + summary: Cancel Creation Of Payment Method + operationId: cancel_creation_of_payment_method + parameters: + - required: true + schema: + title: Wallet Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: wallet_id + in: path + - required: true + schema: + title: Payment Method Id + minLength: 1 + type: string + name: payment_method_id + in: path + responses: + '204': + description: Successfully cancelled + /v0/wallets/{wallet_id}/payments-methods: + get: + tags: + - wallets + summary: List Payments Methods + description: Lists all payments method associated to `wallet_id` + operationId: list_payments_methods + parameters: + - required: true + schema: + title: Wallet Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: wallet_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_models_library.api_schemas_webserver.wallets.PaymentMethodGet__' + /v0/wallets/{wallet_id}/payments-methods/{payment_method_id}: + get: + tags: + - wallets + summary: Get Payment Method + operationId: get_payment_method + parameters: + - required: true + schema: + title: Wallet Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: wallet_id + in: path + - required: true + schema: + title: Payment Method Id + minLength: 1 + type: string + name: payment_method_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_PaymentMethodGet_' + delete: + tags: + - wallets + summary: Delete Payment Method + operationId: delete_payment_method + parameters: + - required: true + schema: + title: Wallet Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: wallet_id + in: path + - required: true + schema: + title: Payment Method Id + minLength: 1 + type: string + name: payment_method_id + in: path + responses: + '204': + description: Successfully deleted /v0/wallets/{wallet_id}/groups/{group_id}: put: tags: @@ -4681,6 +4804,30 @@ components: title: Cluster Id minimum: 0 type: integer + CreatePaymentMethodInitiated: + title: CreatePaymentMethodInitiated + required: + - walletId + - paymentMethodId + - paymentMethodFormUrl + type: object + properties: + walletId: + title: Walletid + exclusiveMinimum: true + type: integer + minimum: 0 + paymentMethodId: + title: Paymentmethodid + minLength: 1 + type: string + paymentMethodFormUrl: + title: Paymentmethodformurl + maxLength: 2083 + minLength: 1 + type: string + description: Link to external site that holds the payment submission form + format: uri CreateWalletBodyParams: title: CreateWalletBodyParams required: @@ -4850,6 +4997,14 @@ components: $ref: '#/components/schemas/ComputationTaskGet' error: title: Error + Envelope_CreatePaymentMethodInitiated_: + title: Envelope[CreatePaymentMethodInitiated] + type: object + properties: + data: + $ref: '#/components/schemas/CreatePaymentMethodInitiated' + error: + title: Error Envelope_Error_: title: Envelope[Error] type: object @@ -4954,6 +5109,14 @@ components: $ref: '#/components/schemas/NodeRetrieved' error: title: Error + Envelope_PaymentMethodGet_: + title: Envelope[PaymentMethodGet] + type: object + properties: + data: + $ref: '#/components/schemas/PaymentMethodGet' + error: + title: Error Envelope_PresignedLink_: title: Envelope[PresignedLink] type: object @@ -5328,6 +5491,17 @@ components: $ref: '#/components/schemas/ServiceRunGet' error: title: Error + Envelope_list_models_library.api_schemas_webserver.wallets.PaymentMethodGet__: + title: Envelope[list[models_library.api_schemas_webserver.wallets.PaymentMethodGet]] + type: object + properties: + data: + title: Data + type: array + items: + $ref: '#/components/schemas/PaymentMethodGet' + error: + title: Error Envelope_list_models_library.api_schemas_webserver.wallets.WalletGetWithAvailableCredits__: title: Envelope[list[models_library.api_schemas_webserver.wallets.WalletGetWithAvailableCredits]] type: object @@ -6679,6 +6853,59 @@ components: properties: value: title: Value + PaymentMethodGet: + title: PaymentMethodGet + required: + - idr + - walletId + - cardHolderName + - cardNumberMasked + - cardType + - expirationMonth + - expirationYear + - streetAddress + - zipcode + - country + - created + type: object + properties: + idr: + title: Idr + minLength: 1 + type: string + walletId: + title: Walletid + exclusiveMinimum: true + type: integer + minimum: 0 + cardHolderName: + title: Cardholdername + type: string + cardNumberMasked: + title: Cardnumbermasked + type: string + cardType: + title: Cardtype + type: string + expirationMonth: + title: Expirationmonth + type: integer + 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 + format: date-time PaymentTransaction: title: PaymentTransaction required: @@ -7795,6 +8022,7 @@ components: - SUCCESS - FAILED - ABORTED + - WAITING_FOR_CLUSTER type: string description: 'State of execution of a project''s computational workflow 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 6ac05d160eb..ffd437c3a4b 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_api.py @@ -9,7 +9,6 @@ PaymentTransaction, WalletPaymentCreated, ) -from models_library.basic_types import IDStr from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder from models_library.wallets import WalletID @@ -29,7 +28,7 @@ _logger = logging.getLogger(__name__) -async def _check_wallet_permissions( +async def check_wallet_permissions( app: web.Application, user_id: UserID, wallet_id: WalletID ): permissions = await get_wallet_with_permissions_by_user( @@ -41,6 +40,31 @@ async def _check_wallet_permissions( ) +def _to_api_model(transaction: _db.PaymentsTransactionsDB) -> PaymentTransaction: + data: dict[str, Any] = { + "payment_id": transaction.payment_id, + "price_dollars": transaction.price_dollars, + "osparc_credits": transaction.osparc_credits, + "wallet_id": transaction.wallet_id, + "created_at": transaction.initiated_at, + "state": transaction.state, + "completed_at": transaction.completed_at, + } + + if transaction.comment: + data["comment"] = transaction.comment + + if transaction.state_message: + data["state_message"] = transaction.state_message + + return PaymentTransaction.parse_obj(data) + + +# +# One-time Payments +# + + async def create_payment_to_wallet( app: web.Application, *, @@ -61,7 +85,7 @@ async def create_payment_to_wallet( user = await get_user_name_and_email(app, user_id=user_id) # check permissions - await _check_wallet_permissions(app, user_id=user_id, wallet_id=wallet_id) + await check_wallet_permissions(app, user_id=user_id, wallet_id=wallet_id) # hold timestamp initiated_at = arrow.utcnow().datetime @@ -96,26 +120,6 @@ async def create_payment_to_wallet( ) -def _to_api_model(transaction: _db.PaymentsTransactionsDB) -> PaymentTransaction: - data: dict[str, Any] = dict( - payment_id=transaction.payment_id, - price_dollars=transaction.price_dollars, - osparc_credits=transaction.osparc_credits, - wallet_id=transaction.wallet_id, - created_at=transaction.initiated_at, - state=transaction.state, - completed_at=transaction.completed_at, - ) - - if transaction.comment: - data["comment"] = transaction.comment - - if transaction.state_message: - data["state_message"] = transaction.state_message - - return PaymentTransaction.parse_obj(data) - - async def get_user_payments_page( app: web.Application, product_name: str, @@ -179,11 +183,11 @@ async def complete_payment( async def cancel_payment_to_wallet( app: web.Application, *, - payment_id: IDStr, + payment_id: PaymentID, user_id: UserID, wallet_id: WalletID, ) -> PaymentTransaction: - await _check_wallet_permissions(app, user_id=user_id, wallet_id=wallet_id) + await check_wallet_permissions(app, user_id=user_id, wallet_id=wallet_id) return await complete_payment( app, diff --git a/services/web/server/src/simcore_service_webserver/payments/_db.py b/services/web/server/src/simcore_service_webserver/payments/_db.py index 8a263f758f9..2a0a6819379 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_db.py +++ b/services/web/server/src/simcore_service_webserver/payments/_db.py @@ -7,7 +7,6 @@ from aiohttp import web from aiopg.sa.result import ResultProxy from models_library.api_schemas_webserver.wallets import PaymentID -from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from models_library.products import ProductName from models_library.users import UserID @@ -34,7 +33,7 @@ # NOTE: this will be moved to the payments service # NOTE: with https://sqlmodel.tiangolo.com/ we would only define this once! class PaymentsTransactionsDB(BaseModel): - payment_id: IDStr + payment_id: PaymentID price_dollars: Decimal # accepts negatives osparc_credits: Decimal # accepts negatives product_name: ProductName 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 new file mode 100644 index 00000000000..ce4c0f230a1 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/payments/_methods_api.py @@ -0,0 +1,233 @@ +import asyncio +import json +import logging +from typing import Any +from uuid import uuid4 + +import arrow +from aiohttp import web +from models_library.api_schemas_webserver.wallets import ( + PaymentMethodGet, + PaymentMethodID, + PaymentMethodInit, +) +from models_library.users import UserID +from models_library.wallets import WalletID +from simcore_postgres_database.models.payments_methods import InitPromptAckFlowState +from yarl import URL + +from ._api import check_wallet_permissions +from ._methods_db import ( + PaymentsMethodsDB, + delete_payment_method, + get_successful_payment_method, + insert_init_payment_method, + list_successful_payment_methods, + udpate_payment_method, +) +from .settings import PaymentsSettings, get_plugin_settings + +_logger = logging.getLogger(__name__) + + +def _to_api_model( + entry: PaymentsMethodsDB, payment_method_details_from_gateway: dict[str, Any] +) -> PaymentMethodGet: + assert entry.completed_at # nosec + + return PaymentMethodGet.parse_obj( + { + **payment_method_details_from_gateway, + "idr": entry.payment_method_id, + "wallet_id": entry.wallet_id, + "created": entry.completed_at, + } + ) + + +# +# Payment-methods +# + + +async def init_creation_of_wallet_payment_method( + app: web.Application, *, user_id: UserID, wallet_id: WalletID +) -> PaymentMethodInit: + """ + + Raises: + WalletAccessForbiddenError + PaymentMethodUniqueViolationError + """ + + # check permissions + await check_wallet_permissions(app, user_id=user_id, wallet_id=wallet_id) + + # hold timestamp + initiated_at = arrow.utcnow().datetime + + # FAKE ----- + _logger.debug("FAKE Payments Gateway: /payment-methods:init") + settings: PaymentsSettings = get_plugin_settings(app) + await asyncio.sleep(1) + payment_method_id = PaymentMethodID(f"{uuid4()}".upper()) + form_link = ( + URL(settings.PAYMENTS_FAKE_GATEWAY_URL) + .with_path("/payment-methods/form") + .with_query(id=payment_method_id) + ) + # ----- + + # annotate + await insert_init_payment_method( + app, + payment_method_id=payment_method_id, + user_id=user_id, + wallet_id=wallet_id, + initiated_at=initiated_at, + ) + + return PaymentMethodInit( + wallet_id=wallet_id, + payment_method_id=payment_method_id, + payment_method_form_url=f"{form_link}", + ) + + +async def _complete_create_of_wallet_payment_method( + app: web.Application, + *, + payment_method_id: PaymentMethodID, + completion_state: InitPromptAckFlowState, + message: str | None = None, +) -> PaymentsMethodsDB: + """Acks as completed (i.e. SUCCESSFUL, FAILED, CANCELED )""" + assert completion_state != InitPromptAckFlowState.PENDING # nosec + + # annotate + updated: PaymentsMethodsDB = await udpate_payment_method( + app, + payment_method_id=payment_method_id, + state=completion_state, + state_message=message, + ) + + return updated + + +async def cancel_creation_of_wallet_payment_method( + app: web.Application, + *, + user_id: UserID, + wallet_id: WalletID, + payment_method_id: PaymentMethodID, +): + """Acks as CANCELED""" + await check_wallet_permissions(app, user_id=user_id, wallet_id=wallet_id) + + await _complete_create_of_wallet_payment_method( + app, + payment_method_id=payment_method_id, + completion_state=InitPromptAckFlowState.CANCELED, + message="Creation of payment-method aborted by user", + ) + # FAKE ----- + _logger.debug( + "FAKE Payments Gateway: DELETE /payment-methods/%s", payment_method_id + ) + await asyncio.sleep(1) + # ----- + + +async def list_wallet_payment_methods( + app: web.Application, *, user_id: UserID, wallet_id: WalletID +) -> list[PaymentMethodGet]: + # check permissions + await check_wallet_permissions(app, user_id=user_id, wallet_id=wallet_id) + + # get acked + acked = await list_successful_payment_methods( + app, + user_id=user_id, + wallet_id=wallet_id, + ) + + # FAKE ----- + _logger.debug( + "FAKE Payments Gateway: POST /payment-methods:batchGet: %s", + json.dumps( + {"payment_methods_ids": [p.payment_method_id for p in acked]}, indent=1 + ), + ) + await asyncio.sleep(1) + + # returns response bodies + # SEE services/payments/src/simcore_service_payments/models/payments_gateway.py + payment_method_details_from_gateway = PaymentMethodGet.Config.schema_extra[ + "examples" + ][0] + + payments_methods: list[PaymentMethodGet] = [ + _to_api_model( + ack, + payment_method_details_from_gateway, + ) + for ack in acked + ] + # ----- + + return payments_methods + + +async def get_wallet_payment_method( + app: web.Application, + *, + user_id: UserID, + wallet_id: WalletID, + payment_method_id: PaymentMethodID, +) -> PaymentMethodGet: + # check permissions + await check_wallet_permissions(app, user_id=user_id, wallet_id=wallet_id) + + acked = await get_successful_payment_method( + app, user_id=user_id, wallet_id=wallet_id, payment_method_id=payment_method_id + ) + + # FAKE ----- + _logger.debug( + "FAKE Payments Gateway: GET /payment-methods/%s", acked.payment_method_id + ) + await asyncio.sleep(1) + payment_method_details_from_gateway = PaymentMethodGet.Config.schema_extra[ + "examples" + ][0] + return _to_api_model(acked, payment_method_details_from_gateway) + # ----- + + +async def delete_wallet_payment_method( + app: web.Application, + *, + user_id: UserID, + wallet_id: WalletID, + payment_method_id: PaymentMethodID, +): + # check permissions + await check_wallet_permissions(app, user_id=user_id, wallet_id=wallet_id) + assert payment_method_id # nosec + + acked = await get_successful_payment_method( + app, user_id=user_id, wallet_id=wallet_id, payment_method_id=payment_method_id + ) + + # FAKE ----- + _logger.debug( + "FAKE Payments Gateway: DELETE /payment-methods/%s", acked.payment_method_id + ) + await asyncio.sleep(1) + # ------ + + # delete since it was deleted from gateway + await delete_payment_method( + app, user_id=user_id, wallet_id=wallet_id, payment_method_id=payment_method_id + ) diff --git a/services/web/server/src/simcore_service_webserver/payments/_methods_db.py b/services/web/server/src/simcore_service_webserver/payments/_methods_db.py new file mode 100644 index 00000000000..a756fb67d04 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/payments/_methods_db.py @@ -0,0 +1,189 @@ +import datetime +import logging + +import simcore_postgres_database.errors as db_errors +import sqlalchemy as sa +from aiohttp import web +from aiopg.sa.result import ResultProxy +from models_library.api_schemas_webserver.wallets import PaymentMethodID +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import BaseModel, parse_obj_as +from simcore_postgres_database.models.payments_methods import ( + InitPromptAckFlowState, + payments_methods, +) +from sqlalchemy import literal_column +from sqlalchemy.sql import func + +from ..db.plugin import get_database_engine +from .errors import ( + PaymentMethodAlreadyAckedError, + PaymentMethodNotFoundError, + PaymentMethodUniqueViolationError, +) + +_logger = logging.getLogger(__name__) + + +# +class PaymentsMethodsDB(BaseModel): + payment_method_id: PaymentMethodID + user_id: UserID + wallet_id: WalletID + # State in Flow + initiated_at: datetime.datetime + completed_at: datetime.datetime | None + state: InitPromptAckFlowState + state_message: str | None + + class Config: + orm_mode = True + + +async def insert_init_payment_method( + app: web.Application, + *, + payment_method_id: str, + user_id: UserID, + wallet_id: WalletID, + initiated_at: datetime.datetime, +) -> None: + async with get_database_engine(app).acquire() as conn: + try: + await conn.execute( + payments_methods.insert().values( + payment_method_id=payment_method_id, + user_id=user_id, + wallet_id=wallet_id, + initiated_at=initiated_at, + ) + ) + except db_errors.UniqueViolation as err: + raise PaymentMethodUniqueViolationError( + payment_method_id=payment_method_id + ) from err + + +async def list_successful_payment_methods( + app, + *, + user_id: UserID, + wallet_id: WalletID, +) -> list[PaymentsMethodsDB]: + async with get_database_engine(app).acquire() as conn: + result: ResultProxy = await conn.execute( + payments_methods.select() + .where( + (payments_methods.c.user_id == user_id) + & (payments_methods.c.wallet_id == wallet_id) + & (payments_methods.c.state == InitPromptAckFlowState.SUCCESS) + ) + .order_by(payments_methods.c.created.desc()) + ) # newest first + rows = await result.fetchall() or [] + return parse_obj_as(list[PaymentsMethodsDB], rows) + + +async def get_successful_payment_method( + app, + *, + user_id: UserID, + wallet_id: WalletID, + payment_method_id: PaymentMethodID, +) -> PaymentsMethodsDB: + async with get_database_engine(app).acquire() as conn: + result: ResultProxy = await conn.execute( + payments_methods.select().where( + (payments_methods.c.user_id == user_id) + & (payments_methods.c.wallet_id == wallet_id) + & (payments_methods.c.payment_method_id == payment_method_id) + & (payments_methods.c.state == InitPromptAckFlowState.SUCCESS) + ) + ) + row = await result.first() + if row is None: + raise PaymentMethodNotFoundError(payment_method_id=payment_method_id) + + return PaymentsMethodsDB.from_orm(row) + + +async def get_pending_payment_methods_ids( + app: web.Application, +) -> list[PaymentMethodID]: + async with get_database_engine(app).acquire() as conn: + result = await conn.execute( + sa.select(payments_methods.c.payment_method_id) + .where(payments_methods.c.completed_at == None) # noqa: E711 + .order_by(payments_methods.c.initiated_at.asc()) # oldest first + ) + rows = await result.fetchall() or [] + return [parse_obj_as(PaymentMethodID, row.payment_method_id) for row in rows] + + +async def udpate_payment_method( + app: web.Application, + payment_method_id: PaymentMethodID, + *, + state: InitPromptAckFlowState, + state_message: str | None, +) -> PaymentsMethodsDB: + """ + + Raises: + PaymentMethodNotFoundError + PaymentMethodCompletedError + """ + if state == InitPromptAckFlowState.PENDING: + msg = f"{state} is not a completion state" + raise ValueError(msg) + + optional = {} + if state_message: + optional["state_message"] = state_message + + async with get_database_engine(app).acquire() as conn, conn.begin(): + row = await ( + await conn.execute( + sa.select( + payments_methods.c.initiated_at, + payments_methods.c.completed_at, + ) + .where(payments_methods.c.payment_method_id == payment_method_id) + .with_for_update() + ) + ).fetchone() + + if row is None: + raise PaymentMethodNotFoundError(payment_method_id=payment_method_id) + + if row.completed_at is not None: + raise PaymentMethodAlreadyAckedError(payment_method_id=payment_method_id) + + result = await conn.execute( + payments_methods.update() + .values(completed_at=func.now(), state=state, **optional) + .where(payments_methods.c.payment_method_id == payment_method_id) + .returning(literal_column("*")) + ) + row = await result.first() + assert row, "execute above should have caught this" # nosec + + return PaymentsMethodsDB.from_orm(row) + + +async def delete_payment_method( + app: web.Application, + *, + user_id: UserID, + wallet_id: WalletID, + payment_method_id: PaymentMethodID, +): + async with get_database_engine(app).acquire() as conn: + await conn.execute( + payments_methods.delete().where( + (payments_methods.c.user_id == user_id) + & (payments_methods.c.wallet_id == wallet_id) + & (payments_methods.c.payment_method_id == payment_method_id) + ) + ) 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 afb53ae21b2..39b9bd5d53f 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_tasks.py +++ b/services/web/server/src/simcore_service_webserver/payments/_tasks.py @@ -5,8 +5,9 @@ from typing import Any from aiohttp import web -from models_library.api_schemas_webserver.wallets import PaymentID +from models_library.api_schemas_webserver.wallets import PaymentID, PaymentMethodID from servicelib.aiohttp.typing_extension import CleanupContextFunc +from simcore_postgres_database.models.payments_methods import InitPromptAckFlowState from simcore_postgres_database.models.payments_transactions import ( PaymentTransactionState, ) @@ -16,6 +17,10 @@ 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 +) +from ._methods_db import get_pending_payment_methods_ids from .settings import get_plugin_settings _logger = logging.getLogger(__name__) @@ -35,14 +40,10 @@ async def _fake_payment_completion(app: web.Application, payment_id: PaymentID): possible_outcomes = [ # 1. Accepted { - "app": app, - "payment_id": payment_id, "completion_state": PaymentTransactionState.SUCCESS, }, # 2. Rejected { - "app": app, - "payment_id": payment_id, "completion_state": PaymentTransactionState.FAILED, "message": "Payment rejected", }, @@ -53,7 +54,38 @@ async def _fake_payment_completion(app: web.Application, payment_id: PaymentID): ) _logger.info("Faking payment completion as %s", kwargs) - await complete_payment(**kwargs) + await complete_payment(app, payment_id=payment_id, **kwargs) + + +async def _fake_payment_method_completion( + app: web.Application, payment_method_id: PaymentMethodID +): + # Fakes processing time + settings = get_plugin_settings(app) + assert settings.PAYMENTS_FAKE_COMPLETION # nosec + await asyncio.sleep(settings.PAYMENTS_FAKE_COMPLETION_DELAY_SEC) + + # Three different possible outcomes + possible_outcomes = [ + # 1. Accepted + { + "completion_state": InitPromptAckFlowState.SUCCESS, + }, + # 2. Rejected + { + "completion_state": InitPromptAckFlowState.FAILED, + "message": "Payment method rejected", + }, + # 3. does not complete ever ??? + ] + kwargs: dict[str, Any] = random.choice( # nosec # noqa: S311 # NOSONAR + possible_outcomes + ) + + _logger.info("Faking payment-method completion as %s", kwargs) + await _complete_create_of_wallet_payment_method( + app, payment_method_id=payment_method_id, **kwargs + ) @retry( @@ -69,6 +101,15 @@ async def _run_resilient_task(app: web.Application): *[_fake_payment_completion(app, payment_id) for payment_id in pending] ) + pending = await get_pending_payment_methods_ids(app) + if pending: + asyncio.gather( + *[ + _fake_payment_method_completion(app, payment_id) + for payment_id in pending + ] + ) + async def _run_periodically(app: web.Application, wait_period_s: float): while True: 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 768dc169e76..c928261af9c 100644 --- a/services/web/server/src/simcore_service_webserver/payments/api.py +++ b/services/web/server/src/simcore_service_webserver/payments/api.py @@ -4,15 +4,23 @@ get_user_payments_page, ) from ._client import get_payments_service_api - -assert cancel_payment_to_wallet # nosec -assert create_payment_to_wallet # nosec -assert get_payments_service_api # nosec -assert get_user_payments_page # nosec +from ._methods_api import ( + cancel_creation_of_wallet_payment_method, + delete_wallet_payment_method, + get_wallet_payment_method, + init_creation_of_wallet_payment_method, + list_wallet_payment_methods, +) __all__: tuple[str, ...] = ( "cancel_payment_to_wallet", "create_payment_to_wallet", "get_payments_service_api", "get_user_payments_page", + "init_creation_of_wallet_payment_method", + "list_wallet_payment_methods", + "get_wallet_payment_method", + "delete_wallet_payment_method", + "cancel_creation_of_wallet_payment_method", ) +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/payments/errors.py b/services/web/server/src/simcore_service_webserver/payments/errors.py index 8eddf3dcec5..a2b36bddf63 100644 --- a/services/web/server/src/simcore_service_webserver/payments/errors.py +++ b/services/web/server/src/simcore_service_webserver/payments/errors.py @@ -1,17 +1,40 @@ from pydantic.errors import PydanticErrorMixin -class PaymentsErrors(PydanticErrorMixin, ValueError): +class PaymentsError(PydanticErrorMixin, ValueError): ... -class PaymentNotFoundError(PaymentsErrors): +class PaymentNotFoundError(PaymentsError): msg_template = "Invalid payment identifier '{payment_id}'" -class PaymentCompletedError(PaymentsErrors): +class PaymentCompletedError(PaymentsError): msg_template = "Cannot complete payment '{payment_id}' that was already closed" -class PaymentUniqueViolationError(PaymentsErrors): +class PaymentUniqueViolationError(PaymentsError): msg_template = "Payment transaction '{payment_id}' aready exists" + + +# +# payment methods +# + + +class PaymentsMethodsError(PydanticErrorMixin, ValueError): + ... + + +class PaymentMethodNotFoundError(PaymentsMethodsError): + msg_template = "Cannot find payment method '{payment_method_id}'" + + +class PaymentMethodAlreadyAckedError(PaymentsMethodsError): + msg_template = ( + "Cannot create payment-method '{payment_method_id}' since it was already closed" + ) + + +class PaymentMethodUniqueViolationError(PaymentsMethodsError): + msg_template = "Payment method '{payment_method_id}' aready exists" diff --git a/services/web/server/src/simcore_service_webserver/payments/settings.py b/services/web/server/src/simcore_service_webserver/payments/settings.py index 05d4074afe7..753efc8c6d1 100644 --- a/services/web/server/src/simcore_service_webserver/payments/settings.py +++ b/services/web/server/src/simcore_service_webserver/payments/settings.py @@ -2,7 +2,7 @@ from functools import cached_property from aiohttp import web -from pydantic import Field, PositiveInt, SecretStr, parse_obj_as, validator +from pydantic import Field, HttpUrl, PositiveInt, SecretStr, parse_obj_as, validator from settings_library.base import BaseCustomSettings from settings_library.basic_types import PortInt, VersionTag from settings_library.utils_service import ( @@ -30,6 +30,8 @@ class PaymentsSettings(BaseCustomSettings, MixinServiceSettings): min_length=10, ) + # NOTE: PAYMENTS_FAKE_* settings are temporary until some features are moved to the payments service + PAYMENTS_FAKE_COMPLETION: bool = Field( default=False, description="Enables fake completion. ONLY for testing purposes" ) @@ -39,6 +41,11 @@ class PaymentsSettings(BaseCustomSettings, MixinServiceSettings): description="Delay in seconds sbefore completion. ONLY for testing purposes", ) + PAYMENTS_FAKE_GATEWAY_URL: HttpUrl = Field( + default=parse_obj_as(HttpUrl, "https://fake-payment-gateway.com"), + description="FAKE Base url to the payment gateway", + ) + @cached_property def api_base_url(self) -> str: # http://payments:8000/v1 diff --git a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py index 2ce598efd71..bb2af230243 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py @@ -25,6 +25,9 @@ from ..login.decorators import login_required from ..payments.errors import ( PaymentCompletedError, + PaymentMethodAlreadyAckedError, + PaymentMethodNotFoundError, + PaymentMethodUniqueViolationError, PaymentNotFoundError, PaymentUniqueViolationError, ) @@ -45,12 +48,15 @@ async def wrapper(request: web.Request) -> web.StreamResponse: except ( WalletNotFoundError, PaymentNotFoundError, + PaymentMethodNotFoundError, ) as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc except ( PaymentUniqueViolationError, PaymentCompletedError, + PaymentMethodAlreadyAckedError, + PaymentMethodUniqueViolationError, ) as exc: raise web.HTTPConflict(reason=f"{exc}") from exc 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 65fda24aa86..40b61505ba6 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 @@ -1,9 +1,12 @@ +import functools import logging from aiohttp import web from models_library.api_schemas_webserver.wallets import ( CreateWalletPayment, PaymentID, + PaymentMethodGet, + PaymentMethodInit, PaymentTransaction, WalletPaymentCreated, ) @@ -14,6 +17,7 @@ parse_request_path_parameters_as, parse_request_query_parameters_as, ) +from servicelib.aiohttp.typing_extension import Handler from servicelib.logging_utils import get_log_record_extra, log_context from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON @@ -22,7 +26,15 @@ from ..application_settings import get_settings from ..login.decorators import login_required from ..payments import api -from ..payments.api import create_payment_to_wallet, get_user_payments_page +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_method, + init_creation_of_wallet_payment_method, + list_wallet_payment_methods, +) from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from ._handlers import ( @@ -37,23 +49,27 @@ routes = web.RouteTableDef() -def _raise_if_not_dev_mode(app): - app_settings = get_settings(app) - if not app_settings.WEBSERVER_DEV_FEATURES_ENABLED: - raise NotImplementedError(MSG_UNDER_DEVELOPMENT) +def requires_dev_feature_enabled(handler: Handler): + @functools.wraps(handler) + async def _handler_under_dev(request: web.Request): + app_settings = get_settings(request.app) + if not app_settings.WEBSERVER_DEV_FEATURES_ENABLED: + raise NotImplementedError(MSG_UNDER_DEVELOPMENT) + return await handler(request) + + return _handler_under_dev @routes.post(f"/{VTAG}/wallets/{{wallet_id}}/payments", name="create_payment") @login_required @permission_required("wallets.*") @handle_wallets_exceptions +@requires_dev_feature_enabled async def create_payment(request: web.Request): req_ctx = WalletsRequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(WalletsPathParams, request) body_params = await parse_request_body_as(CreateWalletPayment, request) - _raise_if_not_dev_mode(request.app) - wallet_id = path_params.wallet_id with log_context( @@ -84,6 +100,7 @@ async def create_payment(request: web.Request): @login_required @permission_required("wallets.*") @handle_wallets_exceptions +@requires_dev_feature_enabled async def list_all_payments(request: web.Request): """Lists all user's payments to any of his wallets @@ -95,8 +112,6 @@ async def list_all_payments(request: web.Request): req_ctx = WalletsRequestContext.parse_obj(request) query_params = parse_request_query_parameters_as(PageQueryParameters, request) - _raise_if_not_dev_mode(request.app) - payments, total_number_of_items = await get_user_payments_page( request.app, user_id=req_ctx.user_id, @@ -129,12 +144,11 @@ class PaymentsPathParams(WalletsPathParams): @login_required @permission_required("wallets.*") @handle_wallets_exceptions +@requires_dev_feature_enabled async def cancel_payment(request: web.Request): req_ctx = WalletsRequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(PaymentsPathParams, request) - _raise_if_not_dev_mode(request.app) - await api.cancel_payment_to_wallet( request.app, user_id=req_ctx.user_id, @@ -143,3 +157,132 @@ async def cancel_payment(request: web.Request): ) return web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) + + +# +# Payment methods +# + + +class PaymentMethodsPathParams(WalletsPathParams): + payment_method_id: PaymentID + + +@routes.post( + f"/{VTAG}/wallets/{{wallet_id}}/payments-methods:init", + name="init_creation_of_payment_method", +) +@login_required +@permission_required("wallets.*") +@handle_wallets_exceptions +@requires_dev_feature_enabled +async def init_creation_of_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 + """ + req_ctx = WalletsRequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(WalletsPathParams, request) + + with log_context( + _logger, + logging.INFO, + "Initated the creation of a payment-method for wallet %s", + f"{path_params.wallet_id=}", + log_duration=True, + extra=get_log_record_extra(user_id=req_ctx.user_id), + ): + initiated: PaymentMethodInit = await init_creation_of_wallet_payment_method( + request.app, user_id=req_ctx.user_id, wallet_id=path_params.wallet_id + ) + + return envelope_json_response(initiated, web.HTTPCreated) + + +@routes.post( + f"/{VTAG}/wallets/{{wallet_id}}/payments-methods/{{payment_method_id}}:cancel", + name="cancel_creation_of_payment_method", +) +@login_required +@permission_required("wallets.*") +@handle_wallets_exceptions +@requires_dev_feature_enabled +async def cancel_creation_of_payment_method(request: web.Request): + req_ctx = WalletsRequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PaymentMethodsPathParams, request) + + with log_context( + _logger, + logging.INFO, + "Cancelled the creation of a payment-method %s for wallet %s", + path_params.payment_method_id, + path_params.wallet_id, + log_duration=True, + extra=get_log_record_extra(user_id=req_ctx.user_id), + ): + await cancel_creation_of_wallet_payment_method( + request.app, + user_id=req_ctx.user_id, + wallet_id=path_params.wallet_id, + payment_method_id=path_params.payment_method_id, + ) + + return web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) + + +@routes.get( + f"/{VTAG}/wallets/{{wallet_id}}/payments-methods", name="list_payments_methods" +) +@login_required +@permission_required("wallets.*") +@handle_wallets_exceptions +@requires_dev_feature_enabled +async def list_payments_methods(request: web.Request): + req_ctx = WalletsRequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(WalletsPathParams, request) + + payments_methods: list[PaymentMethodGet] = await list_wallet_payment_methods( + request.app, user_id=req_ctx.user_id, wallet_id=path_params.wallet_id + ) + return envelope_json_response(payments_methods) + + +@routes.get( + f"/{VTAG}/wallets/{{wallet_id}}/payments-methods/{{payment_method_id}}", + name="get_payment_method", +) +@login_required +@permission_required("wallets.*") +@handle_wallets_exceptions +@requires_dev_feature_enabled +async def get_payment_method(request: web.Request): + req_ctx = WalletsRequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PaymentMethodsPathParams, request) + + payment_method: PaymentMethodGet = await get_wallet_payment_method( + request.app, + user_id=req_ctx.user_id, + wallet_id=path_params.wallet_id, + payment_method_id=path_params.payment_method_id, + ) + return envelope_json_response(payment_method) + + +@routes.delete( + f"/{VTAG}/wallets/{{wallet_id}}/payments-methods/{{payment_method_id}}", + name="delete_payment_method", +) +@login_required +@permission_required("wallets.*") +@handle_wallets_exceptions +@requires_dev_feature_enabled +async def delete_payment_method(request: web.Request): + req_ctx = WalletsRequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PaymentMethodsPathParams, request) + + await delete_wallet_payment_method( + request.app, + user_id=req_ctx.user_id, + wallet_id=path_params.wallet_id, + payment_method_id=path_params.payment_method_id, + ) + return web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) 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 new file mode 100644 index 00000000000..bbfb6fab68a --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/wallets/payments/conftest.py @@ -0,0 +1,55 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +from collections.abc import Callable +from typing import Any, TypeAlias + +import pytest +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_service_webserver.db.models import UserRole + +OpenApiDict: TypeAlias = dict[str, Any] + + +@pytest.fixture +def user_role(): + return UserRole.USER + + +@pytest.fixture +def create_new_wallet(client: TestClient, faker: Faker) -> Callable: + assert client.app + url = client.app.router["create_wallet"].url_for() + + async def _create(): + resp = await client.post( + url.path, + json={ + "name": f"wallet {faker.word()}", + "description": "Fake wallet from create_new_wallet", + }, + ) + data, _ = await assert_status(resp, web.HTTPCreated) + return WalletGet.parse_obj(data) + + return _create + + +@pytest.fixture +async def logged_user_wallet( + client: TestClient, + logged_user: UserInfoDict, + wallets_clean_db: None, + create_new_wallet: Callable, +) -> WalletGet: + assert client.app + return await create_new_wallet() diff --git a/services/web/server/tests/unit/with_dbs/03/wallets/test_payments.py b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py similarity index 89% rename from services/web/server/tests/unit/with_dbs/03/wallets/test_payments.py rename to services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py index 05688335c83..9284b5ab355 100644 --- a/services/web/server/tests/unit/with_dbs/03/wallets/test_payments.py +++ b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py @@ -4,7 +4,6 @@ # pylint: disable=too-many-arguments -from collections.abc import Callable from typing import Any, TypeAlias import pytest @@ -20,11 +19,10 @@ from pydantic import parse_obj_as from pytest_mock import MockerFixture from pytest_simcore.helpers.utils_assert import assert_status -from pytest_simcore.helpers.utils_login import LoggedUser, UserInfoDict +from pytest_simcore.helpers.utils_login import LoggedUser from simcore_postgres_database.models.payments_transactions import ( PaymentTransactionState, ) -from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.payments._api import complete_payment from simcore_service_webserver.payments.errors import PaymentCompletedError from simcore_service_webserver.payments.settings import ( @@ -35,41 +33,6 @@ OpenApiDict: TypeAlias = dict[str, Any] -@pytest.fixture -def user_role(): - return UserRole.USER - - -@pytest.fixture -def create_new_wallet(client: TestClient, faker: Faker) -> Callable: - assert client.app - url = client.app.router["create_wallet"].url_for() - - async def _create(): - resp = await client.post( - url.path, - json={ - "name": f"wallet {faker.word()}", - "description": "Fake wallet from create_new_wallet", - }, - ) - data, _ = await assert_status(resp, web.HTTPCreated) - return WalletGet.parse_obj(data) - - return _create - - -@pytest.fixture -async def logged_user_wallet( - client: TestClient, - logged_user: UserInfoDict, - wallets_clean_db: None, - create_new_wallet: Callable, -) -> WalletGet: - assert client.app - return await create_new_wallet() - - async def test_payment_on_invalid_wallet( client: TestClient, faker: Faker, 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 new file mode 100644 index 00000000000..dd57e393f76 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments_methods.py @@ -0,0 +1,114 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +from aiohttp import web +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.wallets import ( + PaymentMethodGet, + PaymentMethodInit, + WalletGet, +) +from pydantic import parse_obj_as +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, +) +from simcore_service_webserver.payments.settings import ( + PaymentsSettings, + get_plugin_settings, +) + + +async def test_payment_method_worfklow( + client: TestClient, + logged_user_wallet: WalletGet, +): + # preamble + assert client.app + settings: PaymentsSettings = get_plugin_settings(client.app) + + assert settings.PAYMENTS_FAKE_COMPLETION is False + + wallet = logged_user_wallet + + # init Create + response = await client.post( + f"/v0/wallets/{wallet.wallet_id}/payments-methods:init", + ) + data, error = await assert_status(response, web.HTTPCreated) + assert error is None + inited = PaymentMethodInit.parse_obj(data) + + assert inited.payment_method_id + assert inited.payment_method_form_url.query + assert inited.payment_method_form_url.query.endswith(inited.payment_method_id) + + # Get: if I try to get the payment method here, it should fail since the flow is NOT acked! + response = await client.get( + f"/v0/wallets/{wallet.wallet_id}/payments-methods/{inited.payment_method_id}" + ) + await assert_status(response, web.HTTPNotFound) + + # Ack + await _complete_create_of_wallet_payment_method( + client.app, + payment_method_id=inited.payment_method_id, + completion_state=InitPromptAckFlowState.SUCCESS, + message="ACKED by test_add_payment_method_worfklow", + ) + + # Get + response = await client.get( + f"/v0/wallets/{wallet.wallet_id}/payments-methods/{inited.payment_method_id}" + ) + data, _ = await assert_status(response, web.HTTPOk) + payment_method = PaymentMethodGet(**data) + assert payment_method.idr == inited.payment_method_id + + # List + response = await client.get(f"/v0/wallets/{wallet.wallet_id}/payments-methods") + data, _ = await assert_status(response, web.HTTPOk) + + wallet_payments_methods = parse_obj_as(list[PaymentMethodGet], data) + assert wallet_payments_methods == [payment_method] + + # Delete + response = await client.delete( + f"/v0/wallets/{wallet.wallet_id}/payments-methods/{inited.payment_method_id}" + ) + await assert_status(response, web.HTTPNoContent) + + response = await client.get(f"/v0/wallets/{wallet.wallet_id}/payments-methods") + data, _ = await assert_status(response, web.HTTPOk) + assert not data + + +async def test_init_and_cancel_payment_method( + client: TestClient, + logged_user_wallet: WalletGet, +): + wallet = logged_user_wallet + + # init Create + response = await client.post( + f"/v0/wallets/{wallet.wallet_id}/payments-methods:init", + ) + data, error = await assert_status(response, web.HTTPCreated) + assert error is None + inited = PaymentMethodInit.parse_obj(data) + + # cancel Create + response = await client.post( + f"/v0/wallets/{wallet.wallet_id}/payments-methods/{inited.payment_method_id}:cancel", + ) + await assert_status(response, web.HTTPNoContent) + + # Get -> not found + response = await client.get( + f"/v0/wallets/{wallet.wallet_id}/payments-methods/{inited.payment_method_id}" + ) + await assert_status(response, web.HTTPNotFound)