Skip to content

Commit

Permalink
✨ webserver API: exposes payment methods (🗃️) (ITISFoundation#4747)
Browse files Browse the repository at this point in the history
  • Loading branch information
pcrespov authored Sep 14, 2023
1 parent 254a976 commit 199e6fa
Show file tree
Hide file tree
Showing 27 changed files with 1,526 additions and 99 deletions.
2 changes: 1 addition & 1 deletion .env-devel
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions api/specs/web-server/_wallets.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
CreateWalletBodyParams,
CreateWalletPayment,
PaymentID,
PaymentMethodGet,
PaymentMethodID,
PaymentMethodInit,
PaymentTransaction,
PutWalletBodyParams,
WalletGet,
Expand Down Expand Up @@ -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


Expand Down
Binary file added docs/init-prompt-ack_flow.drawio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
},
],
}
Original file line number Diff line number Diff line change
@@ -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 ###
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 199e6fa

Please sign in to comment.