Skip to content

Commit 199e6fa

Browse files
authored
✨ webserver API: exposes payment methods (🗃️) (#4747)
1 parent 254a976 commit 199e6fa

File tree

27 files changed

+1526
-99
lines changed

27 files changed

+1526
-99
lines changed

.env-devel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ PAYMENTS_FAKE_COMPLETION_DELAY_SEC=10
7171
PAYMENTS_FAKE_COMPLETION=0 # NOTE: this can be 1 ONLY if WEBSERVER_DEV_FEATURES_ENABLED=1
7272
PAYMENTS_GATEWAY_API_KEY=replace-with-api-key
7373
PAYMENTS_GATEWAY_API_SECRET=replace-with-api-secret
74-
PAYMENTS_GATEWAY_URL=http://fake-payment-gateway.com
74+
PAYMENTS_GATEWAY_URL=https://fake-payment-gateway.com
7575
PAYMENTS_HOST=payments
7676
PAYMENTS_LOGLEVEL=INFO
7777
PAYMENTS_PASSWORD=adminadmin

api/specs/web-server/_wallets.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
CreateWalletBodyParams,
1515
CreateWalletPayment,
1616
PaymentID,
17+
PaymentMethodGet,
18+
PaymentMethodID,
19+
PaymentMethodInit,
1720
PaymentTransaction,
1821
PutWalletBodyParams,
1922
WalletGet,
@@ -90,6 +93,55 @@ async def cancel_payment(wallet_id: WalletID, payment_id: PaymentID):
9093
...
9194

9295

96+
### Wallets payment-methods
97+
98+
99+
@router.post(
100+
"/wallets/{wallet_id}/payments-methods:init",
101+
response_model=Envelope[PaymentMethodInit],
102+
)
103+
async def init_creation_of_payment_method(wallet_id: WalletID):
104+
...
105+
106+
107+
@router.post(
108+
"/wallets/{wallet_id}/payments-methods/{payment_method_id}:cancel",
109+
status_code=status.HTTP_204_NO_CONTENT,
110+
response_description="Successfully cancelled",
111+
)
112+
async def cancel_creation_of_payment_method(
113+
wallet_id: WalletID, payment_method_id: PaymentMethodID
114+
):
115+
...
116+
117+
118+
@router.get(
119+
"/wallets/{wallet_id}/payments-methods",
120+
response_model=Envelope[list[PaymentMethodGet]],
121+
)
122+
async def list_payments_methods(wallet_id: WalletID):
123+
"""Lists all payments method associated to `wallet_id`"""
124+
125+
126+
@router.get(
127+
"/wallets/{wallet_id}/payments-methods/{payment_method_id}",
128+
response_model=Envelope[PaymentMethodGet],
129+
)
130+
async def get_payment_method(wallet_id: WalletID, payment_method_id: PaymentMethodID):
131+
...
132+
133+
134+
@router.delete(
135+
"/wallets/{wallet_id}/payments-methods/{payment_method_id}",
136+
status_code=status.HTTP_204_NO_CONTENT,
137+
response_description="Successfully deleted",
138+
)
139+
async def delete_payment_method(
140+
wallet_id: WalletID, payment_method_id: PaymentMethodID
141+
):
142+
...
143+
144+
93145
### Wallets groups
94146

95147

64.4 KB
Loading

packages/models-library/src/models_library/api_schemas_webserver/wallets.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
from datetime import datetime
22
from decimal import Decimal
3-
from typing import Literal, TypeAlias
3+
from typing import Any, ClassVar, Literal, TypeAlias
44

5-
from models_library.utils.pydantic_tools_extension import FieldNotRequired
65
from pydantic import Field, HttpUrl
76

87
from ..basic_types import IDStr
@@ -79,3 +78,58 @@ class PaymentTransaction(OutputSchema):
7978
)
8079
state_message: str = FieldNotRequired()
8180
invoice_url: HttpUrl = FieldNotRequired()
81+
82+
83+
PaymentMethodID: TypeAlias = IDStr
84+
85+
86+
class PaymentMethodInit(OutputSchema):
87+
wallet_id: WalletID
88+
payment_method_id: PaymentMethodID
89+
payment_method_form_url: HttpUrl = Field(
90+
..., description="Link to external site that holds the payment submission form"
91+
)
92+
93+
class Config(OutputSchema.Config):
94+
schema_extra: ClassVar[dict[str, Any]] = {
95+
"examples": [
96+
{
97+
"wallet_id": 1,
98+
"payment_method_id": "pm_0987654321",
99+
"payment_method_form_url": "https://example.com/payment-method/form",
100+
}
101+
]
102+
}
103+
104+
105+
class PaymentMethodGet(OutputSchema):
106+
idr: PaymentMethodID
107+
wallet_id: WalletID
108+
card_holder_name: str
109+
card_number_masked: str
110+
card_type: str
111+
expiration_month: int
112+
expiration_year: int
113+
street_address: str
114+
zipcode: str
115+
country: str
116+
created: datetime
117+
118+
class Config(OutputSchema.Config):
119+
schema_extra: ClassVar[dict[str, Any]] = {
120+
"examples": [
121+
{
122+
"idr": "pm_1234567890",
123+
"wallet_id": 1,
124+
"card_holder_name": "John Doe",
125+
"card_number_masked": "**** **** **** 1234",
126+
"card_type": "Visa",
127+
"expiration_month": 10,
128+
"expiration_year": 2025,
129+
"street_address": "123 Main St",
130+
"zipcode": "12345",
131+
"country": "United States",
132+
"created": "2023-09-13T15:30:00Z",
133+
},
134+
],
135+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""New payments_method table
2+
3+
Revision ID: 624a029738b8
4+
Revises: e7b3d381efe4
5+
Create Date: 2023-09-13 15:05:41.094403+00:00
6+
7+
"""
8+
from typing import Final
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "624a029738b8"
15+
down_revision = "e7b3d381efe4"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
# auto-update modified
21+
# TRIGGERS ------------------------
22+
_TABLE_NAME: Final[str] = "payments_methods"
23+
_TRIGGER_NAME: Final[str] = "trigger_auto_update" # NOTE: scoped on table
24+
_PROCEDURE_NAME: Final[
25+
str
26+
] = f"{_TABLE_NAME}_auto_update_modified()" # NOTE: scoped on database
27+
modified_timestamp_trigger = sa.DDL(
28+
f"""
29+
DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME};
30+
CREATE TRIGGER {_TRIGGER_NAME}
31+
BEFORE INSERT OR UPDATE ON {_TABLE_NAME}
32+
FOR EACH ROW EXECUTE PROCEDURE {_PROCEDURE_NAME};
33+
"""
34+
)
35+
36+
# PROCEDURES ------------------------
37+
update_modified_timestamp_procedure = sa.DDL(
38+
f"""
39+
CREATE OR REPLACE FUNCTION {_PROCEDURE_NAME}
40+
RETURNS TRIGGER AS $$
41+
BEGIN
42+
NEW.modified := current_timestamp;
43+
RETURN NEW;
44+
END;
45+
$$ LANGUAGE plpgsql;
46+
"""
47+
)
48+
49+
50+
def upgrade():
51+
# ### commands auto generated by Alembic - please adjust! ###
52+
op.create_table(
53+
"payments_methods",
54+
sa.Column("payment_method_id", sa.String(), nullable=False),
55+
sa.Column("user_id", sa.BigInteger(), nullable=False),
56+
sa.Column("wallet_id", sa.BigInteger(), nullable=False),
57+
sa.Column("initiated_at", sa.DateTime(timezone=True), nullable=False),
58+
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
59+
sa.Column(
60+
"state",
61+
sa.Enum(
62+
"PENDING",
63+
"SUCCESS",
64+
"FAILED",
65+
"CANCELED",
66+
name="initpromptackflowstate",
67+
),
68+
nullable=False,
69+
server_default="PENDING",
70+
),
71+
sa.Column("state_message", sa.Text(), nullable=True),
72+
sa.Column(
73+
"created",
74+
sa.DateTime(timezone=True),
75+
server_default=sa.text("now()"),
76+
nullable=False,
77+
),
78+
sa.Column(
79+
"modified",
80+
sa.DateTime(timezone=True),
81+
server_default=sa.text("now()"),
82+
nullable=False,
83+
),
84+
sa.PrimaryKeyConstraint("payment_method_id"),
85+
)
86+
op.create_index(
87+
op.f("ix_payments_methods_user_id"),
88+
"payments_methods",
89+
["user_id"],
90+
unique=False,
91+
)
92+
op.create_index(
93+
op.f("ix_payments_methods_wallet_id"),
94+
"payments_methods",
95+
["wallet_id"],
96+
unique=False,
97+
)
98+
# ### end Alembic commands ###
99+
100+
# custom
101+
op.execute(update_modified_timestamp_procedure)
102+
op.execute(modified_timestamp_trigger)
103+
104+
105+
def downgrade():
106+
# custom
107+
op.execute(f"DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME};")
108+
op.execute(f"DROP FUNCTION {_PROCEDURE_NAME};")
109+
110+
# ### commands auto generated by Alembic - please adjust! ###
111+
op.drop_index(op.f("ix_payments_methods_wallet_id"), table_name="payments_methods")
112+
op.drop_index(op.f("ix_payments_methods_user_id"), table_name="payments_methods")
113+
op.drop_table("payments_methods")
114+
# ### end Alembic commands ###
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import enum
2+
3+
import sqlalchemy as sa
4+
5+
from ._common import (
6+
column_created_datetime,
7+
column_modified_datetime,
8+
register_modified_datetime_auto_update_trigger,
9+
)
10+
from .base import metadata
11+
12+
13+
@enum.unique
14+
class InitPromptAckFlowState(str, enum.Enum):
15+
PENDING = "PENDING" # initiated
16+
SUCCESS = "SUCCESS" # completed (ack) with success
17+
FAILED = "FAILED" # failed
18+
CANCELED = "CANCELED" # explicitly aborted by user
19+
20+
21+
#
22+
# NOTE:
23+
# - This table was designed to work in an isolated database. For that reason
24+
# we do not use ForeignKeys to establish relations with other tables (e.g. user_id).
25+
# - Payment methods are owned by a user and associated to a wallet. When the same CC is added
26+
# in the framework by different users, the gateway will produce different payment_method_id for each
27+
# of them (VERIFY assumption)
28+
# - A payment method is unique, i.e. only one per wallet and user. For the moment, we intentially avoid the
29+
# possibility of associating a payment method to more than one wallet to avoid complexity
30+
#
31+
payments_methods = sa.Table(
32+
"payments_methods",
33+
metadata,
34+
sa.Column(
35+
"payment_method_id",
36+
sa.String,
37+
nullable=False,
38+
primary_key=True,
39+
doc="Unique identifier of the payment method provided by payment gateway",
40+
),
41+
sa.Column(
42+
"user_id",
43+
sa.BigInteger,
44+
nullable=False,
45+
doc="Unique identifier of the user",
46+
index=True,
47+
),
48+
sa.Column(
49+
"wallet_id",
50+
sa.BigInteger,
51+
nullable=False,
52+
doc="Unique identifier to the wallet owned by the user",
53+
index=True,
54+
),
55+
#
56+
# States of Init-Prompt-Ack flow
57+
#
58+
sa.Column(
59+
"initiated_at",
60+
sa.DateTime(timezone=True),
61+
nullable=False,
62+
doc="Timestamps init step of the flow",
63+
),
64+
sa.Column(
65+
"completed_at",
66+
sa.DateTime(timezone=True),
67+
nullable=True,
68+
doc="Timestamps ack step of the flow",
69+
),
70+
sa.Column(
71+
"state",
72+
sa.Enum(InitPromptAckFlowState),
73+
nullable=False,
74+
default=InitPromptAckFlowState.PENDING,
75+
doc="Current state of this row in the flow ",
76+
),
77+
sa.Column(
78+
"state_message",
79+
sa.Text,
80+
nullable=True,
81+
doc="State message to with details on the state e.g. failure messages",
82+
),
83+
# time-stamps
84+
column_created_datetime(timezone=True),
85+
column_modified_datetime(timezone=True),
86+
)
87+
88+
89+
register_modified_datetime_auto_update_trigger(payments_methods)

packages/postgres-database/src/simcore_postgres_database/models/payments_transactions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ class PaymentTransactionState(str, enum.Enum):
1818
CANCELED = "CANCELED" # payment explicitly aborted by user
1919

2020

21+
#
22+
# NOTE:
23+
# - This table was designed to work in an isolated database. For that reason
24+
# we do not use ForeignKeys to establish relations with other tables (e.g. user_id, product_name, etc).
25+
#
2126
payments_transactions = sa.Table(
2227
"payments_transactions",
2328
metadata,

0 commit comments

Comments
 (0)