Skip to content

Commit f2c5bb0

Browse files
authored
payments service: implementation of apis and db repos for one-time-payment workflow ⚠️ (#4743)
1 parent b43b2d4 commit f2c5bb0

34 files changed

+935
-144
lines changed

services/docker-compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,11 @@ services:
219219
- PAYMENTS_PASSWORD=${PAYMENTS_PASSWORD}
220220
- PAYMENTS_SWAGGER_API_DOC_ENABLED=${PAYMENTS_SWAGGER_API_DOC_ENABLED}
221221
- PAYMENTS_USERNAME=${PAYMENTS_USERNAME}
222+
- POSTGRES_DB=${POSTGRES_DB}
223+
- POSTGRES_HOST=${POSTGRES_HOST}
224+
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
225+
- POSTGRES_PORT=${POSTGRES_PORT}
226+
- POSTGRES_USER=${POSTGRES_USER}
222227
- RABBIT_HOST=${RABBIT_HOST}
223228
- RABBIT_PASSWORD=${RABBIT_PASSWORD}
224229
- RABBIT_PORT=${RABBIT_PORT}

services/payments/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.1.0
1+
1.2.0

services/payments/doc/payments.drawio.svg

Lines changed: 95 additions & 19 deletions
Loading

services/payments/openapi.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"info": {
44
"title": "simcore-service-payments web API",
55
"description": " Service that manages creation and validation of registration payments",
6-
"version": "1.1.0"
6+
"version": "1.2.0"
77
},
88
"paths": {
99
"/": {
@@ -218,6 +218,13 @@
218218
"type": "string",
219219
"title": "Message"
220220
},
221+
"invoice_url": {
222+
"type": "string",
223+
"maxLength": 2083,
224+
"minLength": 1,
225+
"format": "uri",
226+
"title": "Invoice Url"
227+
},
221228
"saved": {
222229
"allOf": [
223230
{
@@ -230,7 +237,8 @@
230237
},
231238
"type": "object",
232239
"required": [
233-
"success"
240+
"success",
241+
"invoice_url"
234242
],
235243
"title": "AckPayment"
236244
},

services/payments/scripts/fake_payment_gateway.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ def cancel_payment(
7777
return router
7878

7979

80-
def auth_session(x_init_api_secret: Annotated[str | None, Header()] = None):
80+
def auth_session(X_Init_Api_Secret: Annotated[str | None, Header()] = None):
81+
# NOTE: keep `X_Init_Api_Secret` with capital letters (even if headers are case-insensitive) to
82+
# to agree with the specs provided by our partners
83+
8184
return 1
8285

8386

services/payments/setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 1.1.0
2+
current_version = 1.2.0
33
commit = True
44
message = services/payments version: {current_version} → {new_version}
55
tag = False
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from typing import Final
2+
3+
ACKED: Final[str] = "Acknoledged"
4+
PAG: Final[str] = "Payments Gateway service"
5+
PGDB: Final[str] = "Postgres service"
6+
RUT: Final[str] = "Resource Usage Tracker service"

services/payments/src/simcore_service_payments/api/rest/_acknowledgements.py

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,26 @@
22
from typing import Annotated
33

44
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
5+
from servicelib.logging_utils import log_context
6+
from simcore_postgres_database.models.payments_transactions import (
7+
PaymentTransactionState,
8+
)
59

10+
from ..._constants import ACKED, PGDB, RUT
11+
from ...db.payments_transactions_repo import (
12+
PaymentNotFoundError,
13+
PaymentsTransactionsRepo,
14+
)
615
from ...models.auth import SessionData
16+
from ...models.db import PaymentsTransactionsDB
717
from ...models.schemas.acknowledgements import (
818
AckPayment,
919
AckPaymentMethod,
1020
PaymentID,
1121
PaymentMethodID,
1222
)
13-
from ._dependencies import get_current_session
23+
from ...services.resource_usage_tracker import ResourceUsageTrackerApi
24+
from ._dependencies import get_current_session, get_repository, get_rut_api
1425

1526
_logger = logging.getLogger(__name__)
1627

@@ -19,34 +30,86 @@
1930

2031

2132
async def on_payment_completed(
22-
payment_id: PaymentID, ack: AckPayment, session: SessionData
33+
transaction: PaymentsTransactionsDB, rut_api: ResourceUsageTrackerApi
2334
):
35+
assert transaction.completed_at is not None # nosec
36+
assert transaction.initiated_at < transaction.completed_at # nosec
37+
38+
_logger.debug("TODO next PR Notify front-end of payment -> sio ")
39+
40+
with log_context(
41+
_logger,
42+
logging.INFO,
43+
"%s: Top-up %s credits for %s",
44+
RUT,
45+
f"{transaction.osparc_credits}",
46+
f"{transaction.payment_id=}",
47+
):
48+
credit_transaction_id = await rut_api.create_credit_transaction(
49+
product_name=transaction.product_name,
50+
wallet_id=transaction.wallet_id,
51+
wallet_name="id={transaction.wallet_id}",
52+
user_id=transaction.user_id,
53+
user_email=transaction.user_email,
54+
osparc_credits=transaction.osparc_credits,
55+
payment_transaction_id=transaction.payment_id,
56+
created_at=transaction.completed_at,
57+
)
58+
2459
_logger.debug(
25-
"payment completed: %s",
26-
f"{payment_id=}, {ack.success=}, {ack.message=}, {session.username=}",
60+
"%s: Response to %s was %s",
61+
RUT,
62+
f"{transaction.payment_id=}",
63+
f"{credit_transaction_id=}",
2764
)
28-
_logger.debug("Notify front-end -> sio ")
29-
_logger.debug("Authorize inc/dec credits -> RUT")
30-
_logger.debug("Annotate RUT response")
3165

3266

3367
@router.post("/payments/{payment_id}:ack")
3468
async def acknowledge_payment(
3569
payment_id: PaymentID,
3670
ack: AckPayment,
37-
session: Annotated[SessionData, Depends(get_current_session)],
71+
_session: Annotated[SessionData, Depends(get_current_session)],
72+
repo: Annotated[
73+
PaymentsTransactionsRepo, Depends(get_repository(PaymentsTransactionsRepo))
74+
],
75+
rut_api: Annotated[ResourceUsageTrackerApi, Depends(get_rut_api)],
3876
background_tasks: BackgroundTasks,
3977
):
4078
"""completes (ie. ack) request initated by `/init` on the payments-gateway API"""
41-
_logger.debug(
42-
"User %s is acknoledging payment with %s as %s", session, f"{payment_id=}", ack
43-
)
44-
_logger.debug("Validate and complete transaction -> DB")
45-
_logger.debug(
46-
"When annotated in db, respond and start a background task with the rest"
47-
)
48-
background_tasks.add_task(on_payment_completed, payment_id, ack, session)
49-
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED)
79+
80+
with log_context(
81+
_logger,
82+
logging.INFO,
83+
"%s: Update %s transaction %s in db",
84+
PGDB,
85+
ACKED,
86+
f"{payment_id=}",
87+
):
88+
try:
89+
transaction = await repo.update_ack_payment_transaction(
90+
payment_id=payment_id,
91+
completion_state=(
92+
PaymentTransactionState.SUCCESS
93+
if ack.success
94+
else PaymentTransactionState.FAILED
95+
),
96+
state_message=ack.message,
97+
invoice_url=ack.invoice_url,
98+
)
99+
except PaymentNotFoundError as err:
100+
raise HTTPException(
101+
status_code=status.HTTP_404_NOT_FOUND, detail=f"{err}"
102+
) from err
103+
104+
if ack.saved:
105+
_logger.debug("%s: Creating payment method", PGDB)
106+
raise HTTPException(
107+
status_code=status.HTTP_501_NOT_IMPLEMENTED,
108+
)
109+
110+
if transaction.state == PaymentTransactionState.SUCCESS:
111+
assert payment_id == transaction.payment_id # nosec
112+
background_tasks.add_task(on_payment_completed, transaction, rut_api)
50113

51114

52115
@router.post("/payments-methods/{payment_method_id}:ack")

services/payments/src/simcore_service_payments/api/rest/_dependencies.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import logging
2-
from typing import Annotated
2+
from collections.abc import AsyncGenerator, Callable
3+
from typing import Annotated, cast
34

45
from fastapi import Depends, Request
56
from fastapi.security import OAuth2PasswordBearer
67
from servicelib.fastapi.dependencies import get_app, get_reverse_url_mapper
8+
from sqlalchemy.ext.asyncio import AsyncEngine
79

810
from ..._meta import API_VTAG
911
from ...core.settings import ApplicationSettings
12+
from ...db.payments_transactions_repo import BaseRepository
1013
from ...models.auth import SessionData
1114
from ...services.auth import get_session_data
15+
from ...services.resource_usage_tracker import ResourceUsageTrackerApi
1216

1317
_logger = logging.getLogger(__name__)
1418

@@ -27,11 +31,32 @@ def get_settings(request: Request) -> ApplicationSettings:
2731
assert get_reverse_url_mapper # nosec
2832
assert get_app # nosec
2933

30-
3134
#
32-
# auth dependencies
35+
# services dependencies
3336
#
3437

38+
39+
def get_rut_api(request: Request) -> ResourceUsageTrackerApi:
40+
return cast(
41+
ResourceUsageTrackerApi, ResourceUsageTrackerApi.get_from_app_state(request.app)
42+
)
43+
44+
45+
def get_db_engine(request: Request) -> AsyncEngine:
46+
engine: AsyncEngine = request.app.state.engine
47+
assert engine # nosec
48+
return engine
49+
50+
51+
def get_repository(repo_type: type[BaseRepository]) -> Callable:
52+
async def _get_repo(
53+
engine: Annotated[AsyncEngine, Depends(get_db_engine)],
54+
) -> AsyncGenerator[BaseRepository, None]:
55+
yield repo_type(db_engine=engine)
56+
57+
return _get_repo
58+
59+
3560
# Implements `password` flow defined in OAuth2
3661
_oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{API_VTAG}/token")
3762

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import logging
2+
3+
from fastapi import HTTPException, Request, status
4+
from fastapi.encoders import jsonable_encoder
5+
from fastapi.responses import JSONResponse
6+
7+
from ...models.schemas.errors import DefaultApiError
8+
9+
_logger = logging.getLogger(__name__)
10+
11+
12+
# NOTE: https://www.starlette.io/exceptions/
13+
# Handled exceptions **do not represent error cases** !
14+
# - They are coerced into appropriate HTTP responses, which are then sent through the standard middleware stack.
15+
# - By default the HTTPException class is used to manage any handled exceptions.
16+
17+
18+
async def http_exception_as_json_response(
19+
request: Request, exc: HTTPException
20+
) -> JSONResponse:
21+
assert request # nosec
22+
error = DefaultApiError.from_status_code(exc.status_code)
23+
24+
error_detail = error.detail or ""
25+
if exc.detail not in error_detail:
26+
# starlette.exceptions.HTTPException default to similar detail
27+
error.detail = exc.detail
28+
29+
return JSONResponse(
30+
jsonable_encoder(error, exclude_none=True), status_code=exc.status_code
31+
)
32+
33+
34+
async def handle_errors_as_500(request: Request, exc: Exception) -> JSONResponse:
35+
assert request # nosec
36+
assert isinstance(exc, Exception) # nosec
37+
38+
error = DefaultApiError.from_status_code(status.HTTP_500_INTERNAL_SERVER_ERROR)
39+
_logger.exception("Unhandled exeption responded as %s", error)
40+
return JSONResponse(
41+
jsonable_encoder(error, exclude_none=True),
42+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
43+
)
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
from fastapi import APIRouter, FastAPI
1+
from fastapi import APIRouter, FastAPI, HTTPException
22

33
from ..._meta import API_VTAG
44
from . import _acknowledgements, _auth, _health, _meta
5+
from ._exceptions import handle_errors_as_500, http_exception_as_json_response
56

67

7-
def setup_rest_api_routes(app: FastAPI):
8+
def setup_rest_api(app: FastAPI):
89
app.include_router(_health.router)
910

1011
api_router = APIRouter(prefix=f"/{API_VTAG}")
1112
api_router.include_router(_auth.router, tags=["auth"])
1213
api_router.include_router(_meta.router, tags=["meta"])
1314
api_router.include_router(_acknowledgements.router, tags=["acks"])
1415
app.include_router(api_router)
16+
17+
app.add_exception_handler(Exception, handle_errors_as_500)
18+
app.add_exception_handler(HTTPException, http_exception_as_json_response)

0 commit comments

Comments
 (0)