diff --git a/.gitignore b/.gitignore index 9836532c4c9..ec92564a31c 100644 --- a/.gitignore +++ b/.gitignore @@ -178,6 +178,5 @@ Untitled* # service settings.schemas.json services/**/settings-schema.json -services/payments/scripts/openapi.json tests/public-api/osparc_python_wheels/* diff --git a/services/payments/README.md b/services/payments/README.md index 0bedf5869ee..0dec186398d 100644 --- a/services/payments/README.md +++ b/services/payments/README.md @@ -1,5 +1,11 @@ -# payments +# payments service - ![[doc/payments.drawio.svg]] +Payment service acts as intermediary between osparc and a `payments-gateway` connected to an external payment system (e.g. stripe, ...). Therefore the +`payments-gateway` acts as a common interface with the finaly payment system to make osparc independent of that decision. The communication +is implemented using http in two directions. This service communicates with a `payments-gateway` service using an API with this specifications [gateway/openapi.json](gateway/openapi.json) +and the latter is configured to acknoledge back to this service (i.e. web-hook) onto this API with the following specs [openapi.json](openapi.json). - - SEE https://github.com/ITISFoundation/osparc-simcore/issues/4657 +Here is a diagram of how this service interacts with the rest of internal and external services +![[doc/payments.drawio.svg]] + +- Further details on the use case and requirements in https://github.com/ITISFoundation/osparc-simcore/issues/4657 diff --git a/services/payments/scripts/Makefile b/services/payments/gateway/Makefile similarity index 59% rename from services/payments/scripts/Makefile rename to services/payments/gateway/Makefile index 166c0763f9d..87709160533 100644 --- a/services/payments/scripts/Makefile +++ b/services/payments/gateway/Makefile @@ -3,12 +3,12 @@ include ../../../scripts/common.Makefile .PHONY: run-devel -run-devel: ## runs fake_payment_gateway server +run-devel: ## runs example_payment_gateway server # SEE http://127.0.0.1:8000/docs set -o allexport; source .env-secret; set +o allexport; \ - uvicorn fake_payment_gateway:the_app --reload - + uvicorn example_payment_gateway:the_app --reload +.PHONY: openapi.json openapi.json: ## creates OAS @set -o allexport; source .env-secret; set +o allexport; \ - python fake_payment_gateway.py openapi > $@ + python example_payment_gateway.py openapi > $@ diff --git a/services/payments/scripts/fake_payment_gateway.py b/services/payments/gateway/example_payment_gateway.py similarity index 92% rename from services/payments/scripts/fake_payment_gateway.py rename to services/payments/gateway/example_payment_gateway.py index 0df5e7b76f2..e43df1d6596 100644 --- a/services/payments/scripts/fake_payment_gateway.py +++ b/services/payments/gateway/example_payment_gateway.py @@ -1,3 +1,9 @@ +""" This is a simple example of a payments-gateway service + + - Mainly used to create the openapi specs (SEE `openapi.json`) that the payments service expects + - Also used as a fake payment-gateway for manual exploratory testing +""" + import argparse import json import logging @@ -48,6 +54,10 @@ logging.basicConfig(level=logging.INFO) +# NOTE: please change every time there is a change in the specs +PAYMENTS_GATEWAY_SPECS_VERSION = "0.3.0" + + class Settings(BaseCustomSettings): PAYMENTS_SERVICE_API_BASE_URL: HttpUrl = "http://replace-with-ack-service.io" PAYMENTS_USERNAME: str = "replace-with_username" @@ -295,7 +305,13 @@ def batch_get_payment_methods( @router.get( "/{id}", response_model=GetPaymentMethod, - responses=ERROR_RESPONSES, + responses={ + "404": { + "model": ErrorModel, + "description": "Payment method not found: It was not added or incomplete (i.e. create flow failed or canceled)", + }, + **ERROR_RESPONSES, + }, ) def get_payment_method( id: PaymentMethodID, @@ -346,11 +362,12 @@ async def _app_lifespan(app: FastAPI): def create_app(): app = FastAPI( - title="fake-payment-gateway", - version="0.3.0", + title="osparc-compliant payment-gateway", + version=PAYMENTS_GATEWAY_SPECS_VERSION, lifespan=_app_lifespan, debug=True, ) + app.openapi_version = "3.0.0" # NOTE: small hack to allow current version of `42Crunch.vscode-openapi` to work with openapi override_fastapi_openapi_method(app) app.state.payments = {} diff --git a/services/payments/gateway/openapi.json b/services/payments/gateway/openapi.json new file mode 100644 index 00000000000..ecc63c61c0f --- /dev/null +++ b/services/payments/gateway/openapi.json @@ -0,0 +1,761 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "osparc-compliant payment-gateway", + "version": "0.3.0" + }, + "paths": { + "/init": { + "post": { + "tags": [ + "payment" + ], + "summary": "Init Payment", + "operationId": "init_payment", + "parameters": [ + { + "required": false, + "schema": { + "type": "string", + "title": "X-Init-Api-Secret" + }, + "name": "x-init-api-secret", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitPayment" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentInitiated" + } + } + } + }, + "4XX": { + "description": "Client Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + } + } + } + } + }, + "/pay": { + "get": { + "tags": [ + "payment" + ], + "summary": "Get Payment Form", + "operationId": "get_payment_form", + "parameters": [ + { + "required": true, + "schema": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Id" + }, + "name": "id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "4XX": { + "description": "Client Error", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/cancel": { + "post": { + "tags": [ + "payment" + ], + "summary": "Cancel Payment", + "operationId": "cancel_payment", + "parameters": [ + { + "required": false, + "schema": { + "type": "string", + "title": "X-Init-Api-Secret" + }, + "name": "x-init-api-secret", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentInitiated" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentCancelled" + } + } + } + }, + "4XX": { + "description": "Client Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + } + } + } + } + }, + "/payment-methods:init": { + "post": { + "tags": [ + "payment-method" + ], + "summary": "Init Payment Method", + "operationId": "init_payment_method", + "parameters": [ + { + "required": false, + "schema": { + "type": "string", + "title": "X-Init-Api-Secret" + }, + "name": "x-init-api-secret", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitPaymentMethod" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentMethodInitiated" + } + } + } + }, + "4XX": { + "description": "Client Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + } + } + } + } + }, + "/payment-methods/form": { + "get": { + "tags": [ + "payment-method" + ], + "summary": "Get Form Payment Method", + "operationId": "get_form_payment_method", + "parameters": [ + { + "required": true, + "schema": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Id" + }, + "name": "id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "4XX": { + "description": "Client Error", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/payment-methods:batchGet": { + "post": { + "tags": [ + "payment-method" + ], + "summary": "Batch Get Payment Methods", + "operationId": "batch_get_payment_methods", + "parameters": [ + { + "required": false, + "schema": { + "type": "string", + "title": "X-Init-Api-Secret" + }, + "name": "x-init-api-secret", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchGetPaymentMethods" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentMethodsBatch" + } + } + } + }, + "4XX": { + "description": "Client Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + } + } + } + } + }, + "/payment-methods/{id}": { + "get": { + "tags": [ + "payment-method" + ], + "summary": "Get Payment Method", + "operationId": "get_payment_method", + "parameters": [ + { + "required": true, + "schema": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Id" + }, + "name": "id", + "in": "path" + }, + { + "required": false, + "schema": { + "type": "string", + "title": "X-Init-Api-Secret" + }, + "name": "x-init-api-secret", + "in": "header" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetPaymentMethod" + } + } + } + }, + "404": { + "description": "Payment method not found: It was not added or incomplete (i.e. create flow failed or canceled)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + } + }, + "4XX": { + "description": "Client Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + } + } + } + }, + "delete": { + "tags": [ + "payment-method" + ], + "summary": "Delete Payment Method", + "operationId": "delete_payment_method", + "parameters": [ + { + "required": true, + "schema": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Id" + }, + "name": "id", + "in": "path" + }, + { + "required": false, + "schema": { + "type": "string", + "title": "X-Init-Api-Secret" + }, + "name": "x-init-api-secret", + "in": "header" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "4XX": { + "description": "Client Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + } + } + } + } + }, + "/payment-methods/{id}:pay": { + "post": { + "tags": [ + "payment-method" + ], + "summary": "Pay With Payment Method", + "operationId": "pay_with_payment_method", + "parameters": [ + { + "required": true, + "schema": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Id" + }, + "name": "id", + "in": "path" + }, + { + "required": false, + "schema": { + "type": "string", + "title": "X-Init-Api-Secret" + }, + "name": "x-init-api-secret", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitPayment" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AckPaymentWithPaymentMethod" + } + } + } + }, + "4XX": { + "description": "Client Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AckPaymentWithPaymentMethod": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "message": { + "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, + "minLength": 1, + "format": "uri", + "title": "Invoice Url", + "description": "Link to invoice is required when success=true" + }, + "payment_id": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Payment Id", + "description": "Payment ID from the gateway" + } + }, + "type": "object", + "required": [ + "success" + ], + "title": "AckPaymentWithPaymentMethod", + "example": { + "success": true, + "provider_payment_id": "pi_123ABC", + "invoice_url": "https://invoices.com/id=12345", + "payment_id": "D19EE68B-B007-4B61-A8BC-32B7115FB244" + } + }, + "BatchGetPaymentMethods": { + "properties": { + "payment_methods_ids": { + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "type": "array", + "title": "Payment Methods Ids" + } + }, + "type": "object", + "required": [ + "payment_methods_ids" + ], + "title": "BatchGetPaymentMethods" + }, + "ErrorModel": { + "properties": { + "message": { + "type": "string", + "title": "Message" + }, + "exception": { + "type": "string", + "title": "Exception" + }, + "file": { + "anyOf": [ + { + "type": "string", + "format": "path" + }, + { + "type": "string" + } + ], + "title": "File" + }, + "line": { + "type": "integer", + "title": "Line" + }, + "trace": { + "items": {}, + "type": "array", + "title": "Trace" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "ErrorModel" + }, + "GetPaymentMethod": { + "properties": { + "id": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Id" + }, + "card_holder_name": { + "type": "string", + "title": "Card Holder Name" + }, + "card_number_masked": { + "type": "string", + "title": "Card Number Masked" + }, + "card_type": { + "type": "string", + "title": "Card Type" + }, + "expiration_month": { + "type": "integer", + "title": "Expiration Month" + }, + "expiration_year": { + "type": "integer", + "title": "Expiration Year" + }, + "created": { + "type": "string", + "format": "date-time", + "title": "Created" + } + }, + "type": "object", + "required": [ + "id", + "created" + ], + "title": "GetPaymentMethod" + }, + "InitPayment": { + "properties": { + "amount_dollars": { + "type": "number", + "exclusiveMaximum": true, + "exclusiveMinimum": true, + "title": "Amount Dollars", + "maximum": 1000000.0, + "minimum": 0.0 + }, + "credits": { + "type": "number", + "exclusiveMaximum": true, + "exclusiveMinimum": true, + "title": "Credits", + "maximum": 1000000.0, + "minimum": 0.0 + }, + "user_name": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "User Name" + }, + "user_email": { + "type": "string", + "format": "email", + "title": "User Email" + }, + "wallet_name": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Wallet Name" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "amount_dollars", + "credits", + "user_name", + "user_email", + "wallet_name" + ], + "title": "InitPayment" + }, + "InitPaymentMethod": { + "properties": { + "method": { + "type": "string", + "enum": [ + "CC" + ], + "title": "Method", + "default": "CC" + }, + "user_name": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "User Name" + }, + "user_email": { + "type": "string", + "format": "email", + "title": "User Email" + }, + "wallet_name": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Wallet Name" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "user_name", + "user_email", + "wallet_name" + ], + "title": "InitPaymentMethod" + }, + "PaymentCancelled": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "title": "PaymentCancelled" + }, + "PaymentInitiated": { + "properties": { + "payment_id": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Payment Id" + } + }, + "type": "object", + "required": [ + "payment_id" + ], + "title": "PaymentInitiated" + }, + "PaymentMethodInitiated": { + "properties": { + "payment_method_id": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Payment Method Id" + } + }, + "type": "object", + "required": [ + "payment_method_id" + ], + "title": "PaymentMethodInitiated" + }, + "PaymentMethodsBatch": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/GetPaymentMethod" + }, + "type": "array", + "title": "Items" + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "PaymentMethodsBatch" + } + } + } +} 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 7dd2b47de76..3004a6709c6 100644 --- a/services/payments/src/simcore_service_payments/services/payments_gateway.py +++ b/services/payments/src/simcore_service_payments/services/payments_gateway.py @@ -1,7 +1,7 @@ """ Interface to communicate with the payment's gateway - httpx client with base_url to PAYMENTS_GATEWAY_URL -- Fake gateway service in services/payments/scripts/fake_payment_gateway.py +- Fake gateway service in services/payments/scripts/example_payment_gateway.py """ @@ -162,6 +162,8 @@ def get_form_payment_method_url(self, id_: PaymentMethodID) -> URL: async def get_many_payment_methods( self, ids_: list[PaymentMethodID] ) -> list[GetPaymentMethod]: + if not ids_: + return [] response = await self.client.post( "/payment-methods:batchGet", json=jsonable_encoder(BatchGetPaymentMethods(payment_methods_ids=ids_)), diff --git a/services/payments/tests/unit/test_services_payments_gateway.py b/services/payments/tests/unit/test_services_payments_gateway.py index ce2a858cc52..8d525e15510 100644 --- a/services/payments/tests/unit/test_services_payments_gateway.py +++ b/services/payments/tests/unit/test_services_payments_gateway.py @@ -219,3 +219,17 @@ async def _go(): assert isinstance(err, PaymentsGatewayError) assert "curl -X POST" in err.get_detailed_message() + + +async def test_payments_gateway_get_batch_with_no_items( + app: FastAPI, + mock_payments_gateway_service_or_none: MockRouter | None, +): + payments_gateway_api: PaymentsGatewayApi = PaymentsGatewayApi.get_from_app_state( + app + ) + assert payments_gateway_api + + # tests issue found in https://github.com/ITISFoundation/appmotion-exchange/issues/16 + empty_list = [] + assert not await payments_gateway_api.get_many_payment_methods(empty_list)