Skip to content

Commit 34b9c0f

Browse files
authored
🐛 Avoids credit transaction on unsuccesful payment (⚠️ devops) (#5106)
1 parent 3e49cd9 commit 34b9c0f

File tree

6 files changed

+236
-59
lines changed

6 files changed

+236
-59
lines changed

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

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@
33

44
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
55
from servicelib.logging_utils import log_context
6-
from simcore_postgres_database.models.payments_methods import InitPromptAckFlowState
7-
from simcore_postgres_database.models.payments_transactions import (
8-
PaymentTransactionState,
9-
)
106

117
from ..._constants import ACKED, PGDB
128
from ...core.errors import PaymentMethodNotFoundError, PaymentNotFoundError
@@ -54,27 +50,21 @@ async def acknowledge_payment(
5450
f"{payment_id=}",
5551
):
5652
try:
57-
transaction = await repo_pay.update_ack_payment_transaction(
58-
payment_id=payment_id,
59-
completion_state=(
60-
PaymentTransactionState.SUCCESS
61-
if ack.success
62-
else PaymentTransactionState.FAILED
63-
),
64-
state_message=ack.message,
65-
invoice_url=ack.invoice_url,
53+
transaction = await payments.acknowledge_one_time_payment(
54+
repo_pay, payment_id=payment_id, ack=ack
6655
)
6756
except PaymentNotFoundError as err:
6857
raise HTTPException(
6958
status_code=status.HTTP_404_NOT_FOUND, detail=f"{err}"
7059
) from err
7160

72-
if transaction.state == PaymentTransactionState.SUCCESS:
73-
assert f"{payment_id}" == f"{transaction.payment_id}" # nosec
74-
background_tasks.add_task(payments.on_payment_completed, transaction, rut_api)
61+
assert f"{payment_id}" == f"{transaction.payment_id}" # nosec
62+
background_tasks.add_task(
63+
payments.on_payment_completed, transaction, rut_api, notify_enabled=True
64+
)
7565

7666
if ack.saved:
77-
created = await payments_methods.create_payment_method(
67+
created = await payments_methods.insert_payment_method(
7868
repo=repo_methods,
7969
payment_method_id=ack.saved.payment_method_id,
8070
user_id=transaction.user_id,
@@ -102,7 +92,6 @@ async def acknowledge_payment_method(
10292
f"{payment_method_id=}",
10393
):
10494
try:
105-
10695
acked = await payments_methods.acknowledge_creation_of_payment_method(
10796
repo=repo, payment_method_id=payment_method_id, ack=ack
10897
)
@@ -111,6 +100,5 @@ async def acknowledge_payment_method(
111100
status_code=status.HTTP_404_NOT_FOUND, detail=f"{err}"
112101
) from err
113102

114-
if acked.state == InitPromptAckFlowState.SUCCESS:
115103
assert f"{payment_method_id}" == f"{acked.payment_method_id}" # nosec
116104
background_tasks.add_task(payments_methods.on_payment_method_completed, acked)

services/payments/src/simcore_service_payments/services/payments.py

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -144,40 +144,47 @@ async def acknowledge_one_time_payment(
144144

145145

146146
async def on_payment_completed(
147-
transaction: PaymentsTransactionsDB, rut_api: ResourceUsageTrackerApi
147+
transaction: PaymentsTransactionsDB,
148+
rut_api: ResourceUsageTrackerApi,
149+
*,
150+
notify_enabled: bool,
148151
):
149152
assert transaction.completed_at is not None # nosec
150153
assert transaction.initiated_at < transaction.completed_at # nosec
151154

152-
_logger.debug(
153-
"Notify front-end of payment -> sio SOCKET_IO_PAYMENT_COMPLETED_EVENT "
154-
)
155-
156-
with log_context(
157-
_logger,
158-
logging.INFO,
159-
"%s: Top-up %s credits for %s",
160-
RUT,
161-
f"{transaction.osparc_credits}",
162-
f"{transaction.payment_id=}",
163-
):
164-
credit_transaction_id = await rut_api.create_credit_transaction(
165-
product_name=transaction.product_name,
166-
wallet_id=transaction.wallet_id,
167-
wallet_name=f"id={transaction.wallet_id}",
168-
user_id=transaction.user_id,
169-
user_email=transaction.user_email,
170-
osparc_credits=transaction.osparc_credits,
171-
payment_transaction_id=transaction.payment_id,
172-
created_at=transaction.completed_at,
155+
if notify_enabled:
156+
_logger.debug(
157+
"Notify front-end of payment -> sio SOCKET_IO_PAYMENT_COMPLETED_EVENT "
158+
"socketio.notify_payment_completed(sio, user_primary_group_id=gid, payment=transaction)"
173159
)
174160

175-
_logger.debug(
176-
"%s: Response to %s was %s",
177-
RUT,
178-
f"{transaction.payment_id=}",
179-
f"{credit_transaction_id=}",
180-
)
161+
if transaction.state == PaymentTransactionState.SUCCESS:
162+
with log_context(
163+
_logger,
164+
logging.INFO,
165+
"%s: Top-up %s credits for %s",
166+
RUT,
167+
f"{transaction.osparc_credits}",
168+
f"{transaction.payment_id=}",
169+
):
170+
assert transaction.state == PaymentTransactionState.SUCCESS # nosec
171+
credit_transaction_id = await rut_api.create_credit_transaction(
172+
product_name=transaction.product_name,
173+
wallet_id=transaction.wallet_id,
174+
wallet_name=f"id={transaction.wallet_id}",
175+
user_id=transaction.user_id,
176+
user_email=transaction.user_email,
177+
osparc_credits=transaction.osparc_credits,
178+
payment_transaction_id=transaction.payment_id,
179+
created_at=transaction.completed_at,
180+
)
181+
182+
_logger.debug(
183+
"%s: Response to %s was %s",
184+
RUT,
185+
f"{transaction.payment_id=}",
186+
f"{credit_transaction_id=}",
187+
)
181188

182189

183190
async def pay_with_payment_method( # noqa: PLR0913
@@ -247,7 +254,8 @@ async def pay_with_payment_method( # noqa: PLR0913
247254
invoice_url=ack.invoice_url,
248255
)
249256

250-
await on_payment_completed(transaction, rut)
257+
# NOTE: notifications here are done as background-task after responding `POST /wallets/{wallet_id}/payments-methods/{payment_method_id}:pay`
258+
await on_payment_completed(transaction, rut, notify_enabled=False)
251259

252260
return transaction.to_api_model()
253261

services/payments/src/simcore_service_payments/services/payments_methods.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,24 +119,25 @@ async def acknowledge_creation_of_payment_method(
119119

120120

121121
async def on_payment_method_completed(payment_method: PaymentsMethodsDB):
122-
assert payment_method.state == InitPromptAckFlowState.SUCCESS # nosec
123122
assert payment_method.completed_at is not None # nosec
124123
assert payment_method.initiated_at < payment_method.completed_at # nosec
125124

126-
_logger.debug(
127-
"Notify front-end of payment -> sio (SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT) "
128-
)
125+
if payment_method.state == InitPromptAckFlowState.SUCCESS:
126+
_logger.debug("Notify front-end of payment-method created! ")
129127

130128

131-
async def create_payment_method(
129+
async def insert_payment_method(
132130
repo: PaymentsMethodsRepo,
133131
*,
134132
payment_method_id: PaymentMethodID,
135133
user_id: UserID,
136134
wallet_id: WalletID,
137135
ack: AckPaymentMethod,
138136
) -> PaymentsMethodsDB:
139-
"""Direct creation of payment-method"""
137+
"""Direct creation of payment-method.
138+
NOTE: that this does NOT communicates with the gateway.
139+
Used e.g. when gateway saved payment method after one-time payment
140+
"""
140141
return await repo.insert_payment_method(
141142
payment_method_id=payment_method_id,
142143
user_id=user_id,

services/payments/tests/unit/conftest.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# pylint: disable=unused-variable
66

77

8-
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
8+
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Iterator
99
from pathlib import Path
1010
from typing import Any, NamedTuple
1111
from unittest.mock import Mock
@@ -20,6 +20,9 @@
2020
from fastapi import FastAPI, status
2121
from fastapi.encoders import jsonable_encoder
2222
from models_library.api_schemas_webserver.wallets import PaymentMethodID
23+
from models_library.users import UserID
24+
from models_library.wallets import WalletID
25+
from pydantic import parse_obj_as
2326
from pytest_mock import MockerFixture
2427
from pytest_simcore.helpers.rawdata_fakers import random_payment_method_data
2528
from pytest_simcore.helpers.typing_env import EnvVarsDict
@@ -29,6 +32,8 @@
2932
from simcore_postgres_database.models.payments_transactions import payments_transactions
3033
from simcore_service_payments.core.application import create_app
3134
from simcore_service_payments.core.settings import ApplicationSettings
35+
from simcore_service_payments.db.payments_methods_repo import PaymentsMethodsRepo
36+
from simcore_service_payments.models.db import PaymentsMethodsDB
3237
from simcore_service_payments.models.payments_gateway import (
3338
BatchGetPaymentMethods,
3439
GetPaymentMethod,
@@ -39,8 +44,10 @@
3944
PaymentMethodsBatch,
4045
)
4146
from simcore_service_payments.models.schemas.acknowledgements import (
47+
AckPaymentMethod,
4248
AckPaymentWithPaymentMethod,
4349
)
50+
from simcore_service_payments.services import payments_methods
4451
from toolz.dicttoolz import get_in
4552

4653
#
@@ -123,7 +130,41 @@ def payments_clean_db(postgres_db: sa.engine.Engine) -> Iterator[None]:
123130
con.execute(payments_transactions.delete())
124131

125132

126-
#
133+
@pytest.fixture
134+
async def create_fake_payment_method_in_db(
135+
app: FastAPI,
136+
) -> AsyncIterable[
137+
Callable[[PaymentMethodID, WalletID, UserID], Awaitable[PaymentsMethodsDB]]
138+
]:
139+
_repo = PaymentsMethodsRepo(app.state.engine)
140+
_created = []
141+
142+
async def _(
143+
payment_method_id: PaymentMethodID,
144+
wallet_id: WalletID,
145+
user_id: UserID,
146+
) -> PaymentsMethodsDB:
147+
acked = await payments_methods.insert_payment_method(
148+
repo=_repo,
149+
payment_method_id=payment_method_id,
150+
user_id=user_id,
151+
wallet_id=wallet_id,
152+
ack=AckPaymentMethod(
153+
success=True,
154+
message=f"Created with {create_fake_payment_method_in_db.__name__}",
155+
),
156+
)
157+
_created.append(acked)
158+
return acked
159+
160+
yield _
161+
162+
for acked in _created:
163+
await _repo.delete_payment_method(
164+
acked.payment_method_id, user_id=acked.user_id, wallet_id=acked.wallet_id
165+
)
166+
167+
127168
MAX_TIME_FOR_APP_TO_STARTUP = 10
128169
MAX_TIME_FOR_APP_TO_SHUTDOWN = 10
129170

@@ -195,7 +236,19 @@ def _cancel_200(request: httpx.Request):
195236

196237

197238
@pytest.fixture
198-
def mock_payments_methods_routes(faker: Faker) -> Iterator[Callable]:
239+
def no_funds_payment_method_id(faker: Faker) -> PaymentMethodID:
240+
"""Fake Paymets-Gateway will decline payments with this payment-method id due to insufficient -funds
241+
242+
USE create_fake_payment_method_in_db to inject this payment-method in DB
243+
Emulates https://stripe.com/docs/testing#declined-payments
244+
"""
245+
return parse_obj_as(PaymentMethodID, "no_funds_payment_method_id")
246+
247+
248+
@pytest.fixture
249+
def mock_payments_methods_routes(
250+
faker: Faker, no_funds_payment_method_id: PaymentMethodID
251+
) -> Iterator[Callable]:
199252
class PaymentMethodInfoTuple(NamedTuple):
200253
init: InitPaymentMethod
201254
get: GetPaymentMethod
@@ -259,6 +312,21 @@ def _pay(request: httpx.Request, pm_id: PaymentMethodID):
259312
_get(request, pm_id)
260313

261314
payment_id = faker.uuid4()
315+
316+
if pm_id == no_funds_payment_method_id:
317+
# SEE https://stripe.com/docs/testing#declined-payments
318+
return httpx.Response(
319+
status.HTTP_200_OK,
320+
json=jsonable_encoder(
321+
AckPaymentWithPaymentMethod(
322+
success=False,
323+
message=f"Insufficient Fonds '{pm_id}'",
324+
invoice_url=None,
325+
payment_id=payment_id,
326+
)
327+
),
328+
)
329+
262330
return httpx.Response(
263331
status.HTTP_200_OK,
264332
json=jsonable_encoder(

services/payments/tests/unit/test_rpc_payments_methods.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
PaymentTransactionState,
3333
)
3434
from simcore_service_payments.models.schemas.acknowledgements import AckPaymentMethod
35-
from simcore_service_payments.services.payments_methods import create_payment_method
35+
from simcore_service_payments.services.payments_methods import insert_payment_method
3636

3737
pytest_simcore_core_services_selection = [
3838
"postgres",
@@ -215,7 +215,7 @@ async def test_webserver_pay_with_payment_method_workflow(
215215
assert app
216216

217217
# faking Payment method
218-
created = await create_payment_method(
218+
created = await insert_payment_method(
219219
repo=PaymentsMethodsRepo(app.state.engine),
220220
payment_method_id=faker.uuid4(),
221221
user_id=user_id,

0 commit comments

Comments
 (0)