diff --git a/app/core/payment/__init__.py b/app/core/checkout/__init__.py similarity index 100% rename from app/core/payment/__init__.py rename to app/core/checkout/__init__.py diff --git a/app/core/payment/payment_tool.py b/app/core/checkout/checkout_tool.py similarity index 76% rename from app/core/payment/payment_tool.py rename to app/core/checkout/checkout_tool.py index a2f4a95e72..e5f0284c7d 100644 --- a/app/core/payment/payment_tool.py +++ b/app/core/checkout/checkout_tool.py @@ -17,8 +17,8 @@ ) from sqlalchemy.ext.asyncio import AsyncSession -from app.core.payment import cruds_payment, models_payment, schemas_payment -from app.core.payment.types_payment import HelloAssoConfig +from app.core.checkout import cruds_checkout, models_checkout, schemas_checkout +from app.core.checkout.types_checkout import HelloAssoConfig, HelloAssoConfigName from app.core.users import schemas_users from app.core.utils import security from app.types.exceptions import ( @@ -35,7 +35,7 @@ hyperion_error_logger = logging.getLogger("hyperion.error") -class PaymentTool: +class CheckoutTool: _access_token: str | None = None _refresh_token: str | None = None _access_token_expiry: int | None = None @@ -48,9 +48,11 @@ class PaymentTool: def __init__( self, + name: HelloAssoConfigName, config: HelloAssoConfig, helloasso_api_base: str, ): + self.name = name self._helloasso_api_base = helloasso_api_base self._auth_client = OAuth2Session( client_id=config.helloasso_client_id, @@ -111,7 +113,7 @@ def get_hello_asso_configuration(self) -> Configuration: retries=3, ) - def is_payment_available(self) -> bool: + def is_checkout_available(self) -> bool: """ If the API credentials are not set, payment won't be available. If credentials are set, this doesn't ensure that they are valid. @@ -132,7 +134,7 @@ async def init_checkout( db: AsyncSession, payer_user: schemas_users.CoreUser | None = None, redirection_uri: str | None = None, - ) -> schemas_payment.Checkout: + ) -> schemas_checkout.Checkout: """ Init an HelloAsso checkout @@ -162,31 +164,37 @@ async def init_checkout( # Thus we catch any exception and log it, then reraise it try: payer: HelloAssoApiV5ModelsCartsCheckoutPayer | None = None - if payer_user: + if payer_user is not None: payer = HelloAssoApiV5ModelsCartsCheckoutPayer( - firstName=payer_user.firstname, - lastName=payer_user.name, + first_name=payer_user.firstname, + last_name=payer_user.name, email=payer_user.email, - dateOfBirth=payer_user.birthday, + date_of_birth=datetime.combine( + payer_user.birthday, + datetime.min.time(), + tzinfo=UTC, + ) + if payer_user.birthday + else None, ) checkout_model_id = uuid.uuid4() secret = security.generate_token(nbytes=12) init_checkout_body = HelloAssoApiV5ModelsCartsInitCheckoutBody( - total_amount=checkout_amount, - initial_amount=checkout_amount, - item_name=checkout_name, - back_url=redirection_uri, - error_url=redirection_uri, - return_url=redirection_uri, - contains_donation=False, + total_amount=checkout_amount, # ty:ignore[unknown-argument] + initial_amount=checkout_amount, # ty:ignore[unknown-argument] + item_name=checkout_name, # ty:ignore[unknown-argument] + back_url=redirection_uri, # ty:ignore[unknown-argument] + error_url=redirection_uri, # ty:ignore[unknown-argument] + return_url=redirection_uri, # ty:ignore[unknown-argument] + contains_donation=False, # ty:ignore[unknown-argument] payer=payer, - metadata=schemas_payment.HelloAssoCheckoutMetadata( + metadata=schemas_checkout.HelloAssoCheckoutMetadata( secret=secret, hyperion_checkout_id=str(checkout_model_id), ).model_dump(), - ) + ) # ty:ignore[missing-argument] # See https://github.com/astral-sh/ty/issues/1438 response: HelloAssoApiV5ModelsCartsInitCheckoutResponse with ApiClient(configuration) as api_client: @@ -196,7 +204,7 @@ async def init_checkout( self._helloasso_slug, init_checkout_body, ) - except (UnauthorizedException, BadRequestException): + except UnauthorizedException, BadRequestException: # We know that HelloAsso may refuse some payer infos, like using the firstname "test" # Even when prefilling the payer infos,the user will be able to edit them on the payment page, # so we can safely retry without the payer infos @@ -204,26 +212,28 @@ async def init_checkout( hyperion_error_logger.exception( f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name} (no payer info provided).", ) - else: - payer_user_name = f"{payer_user.firstname} {payer_user.name}" - hyperion_error_logger.warning( - f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. Retrying without payer infos for {payer_user_name}", + raise + + payer_user_name = f"{payer_user.firstname} {payer_user.name}" + hyperion_error_logger.warning( + f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. Retrying without payer infos for {payer_user_name}", + ) + + init_checkout_body.payer = None + try: + response = checkout_api.organizations_organization_slug_checkout_intents_post( + self._helloasso_slug, + init_checkout_body, + ) + except UnauthorizedException: + # HelloAsso returned a 401 unauthorized again + hyperion_error_logger.exception( + f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}, with and without payer {payer_user_name} infos", ) + raise - init_checkout_body.payer = None - try: - response = checkout_api.organizations_organization_slug_checkout_intents_post( - self._helloasso_slug, - init_checkout_body, - ) - except UnauthorizedException: - # HelloAsso returned a 401 unauthorized again - hyperion_error_logger.exception( - f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}, with and without payer {payer_user_name} infos", - ) - - if response and response.id: - checkout_model = models_payment.Checkout( + if response and response.id and response.redirect_url: + checkout_model = models_checkout.Checkout( id=checkout_model_id, module=module, name=checkout_name, @@ -232,14 +242,14 @@ async def init_checkout( secret=secret, ) - await cruds_payment.create_checkout(db=db, checkout=checkout_model) + await cruds_checkout.create_checkout(db=db, checkout=checkout_model) - return schemas_payment.Checkout( + return schemas_checkout.Checkout( id=checkout_model_id, - payment_url=response.redirect_url, + payment_url=response.redirect_url or "", ) hyperion_error_logger.error( - f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. No checkout id returned", + f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. No checkout id or redirect URL returned", ) raise MissingHelloAssoCheckoutIdError() # noqa: TRY301 @@ -257,8 +267,8 @@ async def get_checkout( self, checkout_id: uuid.UUID, db: AsyncSession, - ) -> schemas_payment.CheckoutComplete | None: - checkout_model = await cruds_payment.get_checkout_by_id( + ) -> schemas_checkout.CheckoutComplete | None: + checkout_model = await cruds_checkout.get_checkout_by_id( checkout_id=checkout_id, db=db, ) @@ -267,12 +277,12 @@ async def get_checkout( checkout_dict = checkout_model.__dict__ checkout_dict["payments"] = [ - schemas_payment.CheckoutPayment(**payment.__dict__) + schemas_checkout.CheckoutPayment(**payment.__dict__) for payment in checkout_dict["payments"] ] - return schemas_payment.CheckoutComplete(**checkout_dict) + return schemas_checkout.CheckoutComplete(**checkout_dict) - async def refund_payment( + async def refund_checkout( self, checkout_id: uuid.UUID, hello_asso_payment_id: int, diff --git a/app/core/payment/cruds_payment.py b/app/core/checkout/cruds_checkout.py similarity index 60% rename from app/core/payment/cruds_payment.py rename to app/core/checkout/cruds_checkout.py index e39fb0e8b1..80a4e731b3 100644 --- a/app/core/payment/cruds_payment.py +++ b/app/core/checkout/cruds_checkout.py @@ -5,13 +5,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.core.payment import models_payment, schemas_payment +from app.core.checkout import models_checkout, schemas_checkout async def create_checkout( db: AsyncSession, - checkout: models_payment.Checkout, -) -> models_payment.Checkout: + checkout: models_checkout.Checkout, +) -> models_checkout.Checkout: db.add(checkout) return checkout @@ -21,22 +21,22 @@ async def get_checkouts( module: str, db: AsyncSession, last_checked: datetime | None = None, -) -> list[schemas_payment.CheckoutComplete]: +) -> list[schemas_checkout.CheckoutComplete]: result = await db.execute( - select(models_payment.Checkout) - .options(selectinload(models_payment.Checkout.payments)) + select(models_checkout.Checkout) + .options(selectinload(models_checkout.Checkout.payments)) .where( - models_payment.Checkout.module == module, + models_checkout.Checkout.module == module, ), ) return [ - schemas_payment.CheckoutComplete( + schemas_checkout.CheckoutComplete( id=checkout.id, module=checkout.module, name=checkout.name, amount=checkout.amount, payments=[ - schemas_payment.CheckoutPayment( + schemas_checkout.CheckoutPayment( id=payment.id, checkout_id=payment.checkout_id, paid_amount=payment.paid_amount, @@ -51,13 +51,13 @@ async def get_checkouts( async def get_checkout_by_id( checkout_id: uuid.UUID, db: AsyncSession, -) -> models_payment.Checkout | None: +) -> models_checkout.Checkout | None: result = await db.execute( - select(models_payment.Checkout) + select(models_checkout.Checkout) .where( - models_payment.Checkout.id == checkout_id, + models_checkout.Checkout.id == checkout_id, ) - .options(selectinload(models_payment.Checkout.payments)), + .options(selectinload(models_checkout.Checkout.payments)), ) return result.scalars().first() @@ -65,10 +65,10 @@ async def get_checkout_by_id( async def get_checkout_by_hello_asso_checkout_id( hello_asso_checkout_id: int, db: AsyncSession, -) -> models_payment.Checkout | None: +) -> models_checkout.Checkout | None: result = await db.execute( - select(models_payment.Checkout).where( - models_payment.Checkout.hello_asso_checkout_id == hello_asso_checkout_id, + select(models_checkout.Checkout).where( + models_checkout.Checkout.hello_asso_checkout_id == hello_asso_checkout_id, ), ) return result.scalars().first() @@ -76,8 +76,8 @@ async def get_checkout_by_hello_asso_checkout_id( async def create_checkout_payment( db: AsyncSession, - checkout_payment: models_payment.CheckoutPayment, -) -> models_payment.CheckoutPayment: + checkout_payment: models_checkout.CheckoutPayment, +) -> models_checkout.CheckoutPayment: db.add(checkout_payment) await db.flush() return checkout_payment @@ -86,10 +86,10 @@ async def create_checkout_payment( async def get_checkout_payment_by_hello_asso_payment_id( hello_asso_payment_id: int, db: AsyncSession, -) -> models_payment.CheckoutPayment | None: +) -> models_checkout.CheckoutPayment | None: result = await db.execute( - select(models_payment.CheckoutPayment).where( - models_payment.CheckoutPayment.hello_asso_payment_id + select(models_checkout.CheckoutPayment).where( + models_checkout.CheckoutPayment.hello_asso_payment_id == hello_asso_payment_id, ), ) diff --git a/app/core/payment/endpoints_payment.py b/app/core/checkout/endpoints_checkout.py similarity index 70% rename from app/core/payment/endpoints_payment.py rename to app/core/checkout/endpoints_checkout.py index 9f31849f3e..f5da188f78 100644 --- a/app/core/payment/endpoints_payment.py +++ b/app/core/checkout/endpoints_checkout.py @@ -8,19 +8,19 @@ from pydantic import TypeAdapter, ValidationError from sqlalchemy.ext.asyncio import AsyncSession -from app.core.payment import cruds_payment, models_payment, schemas_payment -from app.core.payment.types_payment import ( +from app.core.checkout import cruds_checkout, models_checkout, schemas_checkout +from app.core.checkout.types_checkout import ( NotificationResultContent, ) from app.dependencies import get_db from app.module import all_modules from app.types.module import CoreModule -router = APIRouter(tags=["Payments"]) +router = APIRouter(tags=["Checkout"]) core_module = CoreModule( - root="payment", - tag="Payments", + root="checkout", + tag="Checkout", router=router, factory=None, ) @@ -31,11 +31,18 @@ @router.post( "/payment/helloasso/webhook", status_code=204, + deprecated=True, + description="This endpoint is deprecated and will be removed in a future version. Please use /checkout/helloasso/webhook instead.", +) +@router.post( + "/checkout/helloasso/webhook", + status_code=204, ) async def webhook( request: Request, db: AsyncSession = Depends(get_db), ): + body_json = await request.json() try: # We validate the body of the request ourself # to prevent FastAPI from returning a 422 error to HelloAsso @@ -44,11 +51,11 @@ async def webhook( NotificationResultContent, ) content = type_adapter.validate_python( - await request.json(), + body_json, ) if content.metadata: checkout_metadata = ( - schemas_payment.HelloAssoCheckoutMetadata.model_validate( + schemas_checkout.HelloAssoCheckoutMetadata.model_validate( content.metadata, ) ) @@ -56,7 +63,7 @@ async def webhook( checkout_metadata = None except ValidationError: hyperion_error_logger.exception( - f"Payment: could not validate the webhook body: {await request.json()}, failed", + f"Payment: could not validate the webhook body: {body_json}, failed", ) raise HTTPException( status_code=400, @@ -74,7 +81,7 @@ async def webhook( # We may receive the webhook multiple times, we only want to save a CheckoutPayment # in the database the first time existing_checkout_payment_model = ( - await cruds_payment.get_checkout_payment_by_hello_asso_payment_id( + await cruds_checkout.get_checkout_payment_by_hello_asso_payment_id( hello_asso_payment_id=content.data.id, db=db, ) @@ -92,7 +99,7 @@ async def webhook( ) return - checkout = await cruds_payment.get_checkout_by_id( + checkout = await cruds_checkout.get_checkout_by_id( checkout_id=uuid.UUID(checkout_metadata.hyperion_checkout_id), db=db, ) @@ -116,14 +123,14 @@ async def webhook( detail="Secret mismatch", ) - checkout_payment_model = models_payment.CheckoutPayment( + checkout_payment_model = models_checkout.CheckoutPayment( id=uuid.uuid4(), checkout_id=checkout.id, paid_amount=content.data.amount, tip_amount=content.data.amountTip, hello_asso_payment_id=content.data.id, ) - await cruds_payment.create_checkout_payment( + await cruds_checkout.create_checkout_payment( checkout_payment=checkout_payment_model, db=db, ) @@ -136,20 +143,28 @@ async def webhook( try: for module in all_modules: if module.root == checkout.module: - if module.payment_callback is not None: + if module.checkout_callback is None: hyperion_error_logger.info( f"Payment: calling module {checkout.module} payment callback", ) - checkout_payment_schema = ( - schemas_payment.CheckoutPayment.model_validate( - checkout_payment_model.__dict__, - ) - ) - await module.payment_callback(checkout_payment_schema, db) - hyperion_error_logger.info( - f"Payment: call to module {checkout.module} payment callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id}) succeeded", - ) return + hyperion_error_logger.info( + f"Payment: calling module {checkout.module} payment callback", + ) + checkout_payment_schema = schemas_checkout.CheckoutPayment( + id=checkout_payment_model.id, + paid_amount=checkout_payment_model.paid_amount, + checkout_id=checkout_payment_model.checkout_id, + ) + await module.checkout_callback(checkout_payment_schema, db) + hyperion_error_logger.info( + f"Payment: call to module {checkout.module} payment callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id}) succeeded", + ) + return + + hyperion_error_logger.info( + f"Payment: callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id}) was not called for module {checkout.module}", + ) except Exception: hyperion_error_logger.exception( f"Payment: call to module {checkout.module} payment callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id}) failed", diff --git a/app/core/payment/models_payment.py b/app/core/checkout/models_checkout.py similarity index 100% rename from app/core/payment/models_payment.py rename to app/core/checkout/models_checkout.py diff --git a/app/core/payment/schemas_payment.py b/app/core/checkout/schemas_checkout.py similarity index 100% rename from app/core/payment/schemas_payment.py rename to app/core/checkout/schemas_checkout.py diff --git a/app/core/payment/types_payment.py b/app/core/checkout/types_checkout.py similarity index 97% rename from app/core/payment/types_payment.py rename to app/core/checkout/types_checkout.py index 79b03820f7..41a6140d97 100644 --- a/app/core/payment/types_payment.py +++ b/app/core/checkout/types_checkout.py @@ -1,116 +1,116 @@ -from datetime import datetime -from enum import Enum -from typing import Any, Literal - -from helloasso_python.models.hello_asso_api_v5_models_api_notifications_api_notification_type import ( - HelloAssoApiV5ModelsApiNotificationsApiNotificationType, -) -from helloasso_python.models.hello_asso_api_v5_models_carts_checkout_payer import ( - HelloAssoApiV5ModelsCartsCheckoutPayer, -) -from helloasso_python.models.hello_asso_api_v5_models_common_meta_model import ( - HelloAssoApiV5ModelsCommonMetaModel, -) -from helloasso_python.models.hello_asso_api_v5_models_enums_payment_means import ( - HelloAssoApiV5ModelsEnumsPaymentMeans, -) -from helloasso_python.models.hello_asso_api_v5_models_enums_payment_state import ( - HelloAssoApiV5ModelsEnumsPaymentState, -) -from helloasso_python.models.hello_asso_api_v5_models_enums_payment_type import ( - HelloAssoApiV5ModelsEnumsPaymentType, -) -from helloasso_python.models.hello_asso_api_v5_models_statistics_refund_operation_light_model import ( - HelloAssoApiV5ModelsStatisticsRefundOperationLightModel, -) -from pydantic import BaseModel - -""" -We are forced to hardcode the following models because they are not available in the helloasso-python package. -According to the swagger we should use a model called `Models.Orders.PaymentDetail` which doesn't seem to exist - - -The closest model in term of field is `HelloAssoApiV5ModelsStatisticsPaymentDetail` -which does not contain the field `date` expected in the documentation example: https://dev.helloasso.com/docs/notification-exemple#paiement-autoris%C3%A9-sur-un-checkout -""" - - -class HelloAssoConfigName(Enum): - CDR = "CDR" - RAID = "RAID" - MYPAYMENT = "MYPAYMENT" - CHALLENGER = "CHALLENGER" - - -class HelloAssoConfig(BaseModel): - helloasso_client_id: str - helloasso_client_secret: str - helloasso_slug: str - redirection_uri: str | None = None - - -class PaymentDetail(BaseModel): - payer: HelloAssoApiV5ModelsCartsCheckoutPayer | None = None - - id: int - amount: int - amountTip: int | None = None - date: datetime | None = None - installmentNumber: int | None = None - state: HelloAssoApiV5ModelsEnumsPaymentState | None = None - type: HelloAssoApiV5ModelsEnumsPaymentType | None = None - meta: HelloAssoApiV5ModelsCommonMetaModel | None = None - paymentOffLineMean: HelloAssoApiV5ModelsEnumsPaymentMeans | None = None - refundOperations: ( - list[HelloAssoApiV5ModelsStatisticsRefundOperationLightModel] | None - ) = None - - -class OrganizationNotificationResultData(BaseModel): - old_slug_organization: str - new_slug_organization: str - - -class OrganizationNotificationResultContent(BaseModel): - eventType: Literal[ - HelloAssoApiV5ModelsApiNotificationsApiNotificationType.ORGANIZATION - ] - data: OrganizationNotificationResultData - metadata: None = None # not sure - - -class OrderNotificationResultContent(BaseModel): - """ - metadata should contain the metadata sent while creating the checkout intent in `InitCheckoutBody` - """ - - eventType: Literal[HelloAssoApiV5ModelsApiNotificationsApiNotificationType.ORDER] - data: dict[str, Any] - metadata: dict[str, Any] | None = None - - -class PayementNotificationResultContent(BaseModel): - """ - metadata should contain the metadata sent while creating the checkout intent in `InitCheckoutBody` - """ - - eventType: Literal[HelloAssoApiV5ModelsApiNotificationsApiNotificationType.PAYMENT] - data: PaymentDetail - metadata: dict[str, Any] | None = None - - -class FormNotificationResultContent(BaseModel): - eventType: Literal[HelloAssoApiV5ModelsApiNotificationsApiNotificationType.FORM] - data: dict[str, Any] - metadata: dict[str, Any] | None = None # not sure - - -NotificationResultContent = ( - OrganizationNotificationResultContent - | OrderNotificationResultContent - | PayementNotificationResultContent - | FormNotificationResultContent -) -""" -When a new content is available, HelloAsso will call the notification URL callback with the corresponding data in the body. -""" +from datetime import datetime +from enum import Enum +from typing import Any, Literal + +from helloasso_python.models.hello_asso_api_v5_models_api_notifications_api_notification_type import ( + HelloAssoApiV5ModelsApiNotificationsApiNotificationType, +) +from helloasso_python.models.hello_asso_api_v5_models_carts_checkout_payer import ( + HelloAssoApiV5ModelsCartsCheckoutPayer, +) +from helloasso_python.models.hello_asso_api_v5_models_common_meta_model import ( + HelloAssoApiV5ModelsCommonMetaModel, +) +from helloasso_python.models.hello_asso_api_v5_models_enums_payment_means import ( + HelloAssoApiV5ModelsEnumsPaymentMeans, +) +from helloasso_python.models.hello_asso_api_v5_models_enums_payment_state import ( + HelloAssoApiV5ModelsEnumsPaymentState, +) +from helloasso_python.models.hello_asso_api_v5_models_enums_payment_type import ( + HelloAssoApiV5ModelsEnumsPaymentType, +) +from helloasso_python.models.hello_asso_api_v5_models_statistics_refund_operation_light_model import ( + HelloAssoApiV5ModelsStatisticsRefundOperationLightModel, +) +from pydantic import BaseModel + +""" +We are forced to hardcode the following models because they are not available in the helloasso-python package. +According to the swagger we should use a model called `Models.Orders.PaymentDetail` which doesn't seem to exist + + +The closest model in term of field is `HelloAssoApiV5ModelsStatisticsPaymentDetail` +which does not contain the field `date` expected in the documentation example: https://dev.helloasso.com/docs/notification-exemple#paiement-autoris%C3%A9-sur-un-checkout +""" + + +class HelloAssoConfigName(Enum): + CDR = "CDR" + RAID = "RAID" + MYPAYMENT = "MYPAYMENT" + CHALLENGER = "CHALLENGER" + + +class HelloAssoConfig(BaseModel): + helloasso_client_id: str + helloasso_client_secret: str + helloasso_slug: str + redirection_uri: str | None = None + + +class PaymentDetail(BaseModel): + payer: HelloAssoApiV5ModelsCartsCheckoutPayer | None = None + + id: int + amount: int + amountTip: int | None = None + date: datetime | None = None + installmentNumber: int | None = None + state: HelloAssoApiV5ModelsEnumsPaymentState | None = None + type: HelloAssoApiV5ModelsEnumsPaymentType | None = None + meta: HelloAssoApiV5ModelsCommonMetaModel | None = None + paymentOffLineMean: HelloAssoApiV5ModelsEnumsPaymentMeans | None = None + refundOperations: ( + list[HelloAssoApiV5ModelsStatisticsRefundOperationLightModel] | None + ) = None + + +class OrganizationNotificationResultData(BaseModel): + old_slug_organization: str + new_slug_organization: str + + +class OrganizationNotificationResultContent(BaseModel): + eventType: Literal[ + HelloAssoApiV5ModelsApiNotificationsApiNotificationType.ORGANIZATION + ] + data: OrganizationNotificationResultData + metadata: None = None # not sure + + +class OrderNotificationResultContent(BaseModel): + """ + metadata should contain the metadata sent while creating the checkout intent in `InitCheckoutBody` + """ + + eventType: Literal[HelloAssoApiV5ModelsApiNotificationsApiNotificationType.ORDER] + data: dict[str, Any] + metadata: dict[str, Any] | None = None + + +class PayementNotificationResultContent(BaseModel): + """ + metadata should contain the metadata sent while creating the checkout intent in `InitCheckoutBody` + """ + + eventType: Literal[HelloAssoApiV5ModelsApiNotificationsApiNotificationType.PAYMENT] + data: PaymentDetail + metadata: dict[str, Any] | None = None + + +class FormNotificationResultContent(BaseModel): + eventType: Literal[HelloAssoApiV5ModelsApiNotificationsApiNotificationType.FORM] + data: dict[str, Any] + metadata: dict[str, Any] | None = None # not sure + + +NotificationResultContent = ( + OrganizationNotificationResultContent + | OrderNotificationResultContent + | PayementNotificationResultContent + | FormNotificationResultContent +) +""" +When a new content is available, HelloAsso will call the notification URL callback with the corresponding data in the body. +""" diff --git a/app/core/checkout/utils_checkout.py b/app/core/checkout/utils_checkout.py new file mode 100644 index 0000000000..de31c415d7 --- /dev/null +++ b/app/core/checkout/utils_checkout.py @@ -0,0 +1 @@ +CHECKOUT_EXPIRATION = 15 diff --git a/app/core/groups/groups_type.py b/app/core/groups/groups_type.py index cf2652a18d..d675f7290d 100644 --- a/app/core/groups/groups_type.py +++ b/app/core/groups/groups_type.py @@ -1,7 +1,7 @@ -from enum import Enum +from enum import StrEnum -class GroupType(str, Enum): +class GroupType(StrEnum): """ In Hyperion, each user may have multiple groups. Belonging to a group gives access to a set of specific permissions. @@ -14,7 +14,7 @@ class GroupType(str, Enum): admin = "0a25cb76-4b63-4fd3-b939-da6d9feabf28" -class AccountType(str, Enum): +class AccountType(StrEnum): """ Various account types that can be created in Hyperion. Each account type is associated with a set of permissions. diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index 1fc5056ee4..68eed79185 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -1,6 +1,6 @@ import logging from collections.abc import Sequence -from datetime import datetime +from datetime import UTC, datetime, timedelta from uuid import UUID from sqlalchemy import and_, delete, or_, select, update @@ -10,11 +10,13 @@ from app.core.mypayment import models_mypayment, schemas_mypayment from app.core.mypayment.exceptions_mypayment import WalletNotFoundOnUpdateError from app.core.mypayment.types_mypayment import ( + REQUEST_EXPIRATION, + RequestStatus, TransactionStatus, WalletDeviceStatus, WalletType, ) -from app.core.mypayment.utils.schema_converters import ( +from app.core.mypayment.utils.models_converter import ( invoice_model_to_schema, refund_model_to_schema, structure_model_to_schema, @@ -219,7 +221,7 @@ async def update_store( await db.execute( update(models_mypayment.Store) .where(models_mypayment.Store.id == store_id) - .values(**store_update.model_dump(exclude_none=True)), + .values(**store_update.model_dump(exclude_unset=True)), ) @@ -251,6 +253,18 @@ async def get_store_by_name( return result.scalars().first() +async def get_store_by_id( + store_id: UUID, + db: AsyncSession, +) -> models_mypayment.Store | None: + result = await db.execute( + select(models_mypayment.Store).where( + models_mypayment.Store.id == store_id, + ), + ) + return result.scalars().first() + + async def get_stores_by_structure_id( db: AsyncSession, structure_id: UUID, @@ -270,6 +284,7 @@ async def create_seller( can_see_history: bool, can_cancel: bool, can_manage_sellers: bool, + can_manage_events: bool, db: AsyncSession, ) -> None: wallet = models_mypayment.Seller( @@ -279,6 +294,7 @@ async def create_seller( can_see_history=can_see_history, can_cancel=can_cancel, can_manage_sellers=can_manage_sellers, + can_manage_events=can_manage_events, ) db.add(wallet) @@ -308,6 +324,7 @@ async def get_seller( can_see_history=result.can_see_history, can_cancel=result.can_cancel, can_manage_sellers=result.can_manage_sellers, + can_manage_events=result.can_manage_events, user=schemas_users.CoreUserSimple( id=result.user.id, firstname=result.user.firstname, @@ -339,6 +356,7 @@ async def get_sellers_by_store_id( can_see_history=seller.can_see_history, can_cancel=seller.can_cancel, can_manage_sellers=seller.can_manage_sellers, + can_manage_events=seller.can_manage_events, user=schemas_users.CoreUserSimple( id=seller.user.id, firstname=seller.user.firstname, @@ -377,7 +395,7 @@ async def update_seller( models_mypayment.Seller.store_id == store_id, ) .values( - **seller_update.model_dump(exclude_none=True), + **seller_update.model_dump(exclude_unset=True), ), ) @@ -568,7 +586,7 @@ async def get_user_payment( async def create_transaction( transaction: schemas_mypayment.TransactionBase, - debited_wallet_device_id: UUID, + debited_wallet_device_id: UUID | None, store_note: str | None, db: AsyncSession, ) -> None: @@ -755,30 +773,70 @@ async def get_transfers( schemas_mypayment.Transfer( id=transfer.id, type=transfer.type, + origin=transfer.origin, transfer_identifier=transfer.transfer_identifier, approver_user_id=transfer.approver_user_id, wallet_id=transfer.wallet_id, total=transfer.total, creation=transfer.creation, confirmed=transfer.confirmed, + module=transfer.module, + object_id=transfer.object_id, ) for transfer in result.scalars().all() ] +async def get_transfer_by_object_id( + object_id: UUID, + db: AsyncSession, +) -> schemas_mypayment.Transfer | None: + result = ( + ( + await db.execute( + select(models_mypayment.Transfer).where( + models_mypayment.Transfer.object_id == object_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_mypayment.Transfer( + id=result.id, + type=result.type, + origin=result.origin, + transfer_identifier=result.transfer_identifier, + approver_user_id=result.approver_user_id, + wallet_id=result.wallet_id, + total=result.total, + creation=result.creation, + confirmed=result.confirmed, + module=result.module, + object_id=result.object_id, + ) + if result + else None + ) + + async def create_transfer( - transfer: schemas_mypayment.Transfer, + transfer: schemas_mypayment.TransferCreation, db: AsyncSession, ) -> None: transfer_db = models_mypayment.Transfer( id=transfer.id, - type=transfer.type, + origin=transfer.origin, transfer_identifier=transfer.transfer_identifier, approver_user_id=transfer.approver_user_id, wallet_id=transfer.wallet_id, total=transfer.total, creation=transfer.creation, confirmed=transfer.confirmed, + module=transfer.module, + object_id=transfer.object_id, ) db.add(transfer_db) @@ -799,7 +857,7 @@ async def get_transfers_by_wallet_id( db: AsyncSession, start_datetime: datetime | None = None, end_datetime: datetime | None = None, -) -> Sequence[models_mypayment.Transfer]: +) -> list[schemas_mypayment.Transfer]: result = await db.execute( select(models_mypayment.Transfer) .where( @@ -814,7 +872,22 @@ async def get_transfers_by_wallet_id( else and_(True), ), ) - return result.scalars().all() + return [ + schemas_mypayment.Transfer( + id=transfer.id, + type=transfer.type, + origin=transfer.origin, + transfer_identifier=transfer.transfer_identifier, + approver_user_id=transfer.approver_user_id, + wallet_id=transfer.wallet_id, + total=transfer.total, + creation=transfer.creation, + confirmed=transfer.confirmed, + module=transfer.module, + object_id=transfer.object_id, + ) + for transfer in result.scalars().all() + ] async def get_transfers_and_sellers_by_wallet_id( @@ -856,13 +929,37 @@ async def get_transfers_and_sellers_by_wallet_id( async def get_transfer_by_transfer_identifier( db: AsyncSession, transfer_identifier: str, -) -> models_mypayment.Transfer | None: - result = await db.execute( - select(models_mypayment.Transfer).where( - models_mypayment.Transfer.transfer_identifier == transfer_identifier, - ), +) -> schemas_mypayment.Transfer | None: + result = ( + ( + await db.execute( + select(models_mypayment.Transfer).where( + models_mypayment.Transfer.transfer_identifier + == transfer_identifier, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_mypayment.Transfer( + id=result.id, + type=result.type, + origin=result.origin, + transfer_identifier=result.transfer_identifier, + approver_user_id=result.approver_user_id, + wallet_id=result.wallet_id, + total=result.total, + creation=result.creation, + confirmed=result.confirmed, + module=result.module, + object_id=result.object_id, + ) + if result + else None ) - return result.scalars().first() async def get_refunds( @@ -1006,6 +1103,174 @@ async def get_refunds_and_sellers_by_wallet_id( return refunds_with_sellers +async def get_requests_by_wallet_id( + wallet_id: UUID, + db: AsyncSession, + include_used: bool = False, +) -> list[schemas_mypayment.Request]: + result = await db.execute( + select(models_mypayment.Request).where( + models_mypayment.Request.wallet_id == wallet_id, + and_( + models_mypayment.Request.status == RequestStatus.PROPOSED, + models_mypayment.Request.creation + > datetime.now(tz=UTC) - timedelta(minutes=REQUEST_EXPIRATION), + ) + if not include_used + else and_(True), + ), + ) + return [ + schemas_mypayment.Request( + id=request.id, + wallet_id=request.wallet_id, + status=request.status, + creation=request.creation, + expiration_date=request.expiration_date, + total=request.total, + store_note=request.store_note, + store_id=request.store_id, + name=request.name, + module=request.module, + object_id=request.object_id, + ) + for request in result.scalars().all() + ] + + +async def get_request_by_id( + request_id: UUID, + db: AsyncSession, +) -> schemas_mypayment.Request | None: + """ + Return a request by its id. The request will be locked `for update` + """ + result = await db.execute( + select(models_mypayment.Request) + .where( + models_mypayment.Request.id == request_id, + ) + .with_for_update(of=models_mypayment.Request), + ) + request = result.scalars().first() + return ( + schemas_mypayment.Request( + id=request.id, + wallet_id=request.wallet_id, + status=request.status, + creation=request.creation, + expiration_date=request.expiration_date, + total=request.total, + store_note=request.store_note, + store_id=request.store_id, + name=request.name, + module=request.module, + object_id=request.object_id, + ) + if request + else None + ) + + +async def get_request_by_object_id( + object_id: UUID, + db: AsyncSession, +) -> schemas_mypayment.Request | None: + result = await db.execute( + select(models_mypayment.Request).where( + models_mypayment.Request.object_id == object_id, + ), + ) + request = result.scalars().first() + return ( + schemas_mypayment.Request( + id=request.id, + wallet_id=request.wallet_id, + status=request.status, + creation=request.creation, + expiration_date=request.expiration_date, + total=request.total, + store_note=request.store_note, + store_id=request.store_id, + name=request.name, + module=request.module, + object_id=request.object_id, + ) + if request + else None + ) + + +async def get_request_by_store_id( + store_id: UUID, + db: AsyncSession, +) -> list[schemas_mypayment.Request]: + result = await db.execute( + select(models_mypayment.Request).where( + models_mypayment.Request.store_id == store_id, + ), + ) + return [ + schemas_mypayment.Request( + id=request.id, + wallet_id=request.wallet_id, + status=request.status, + creation=request.creation, + expiration_date=request.expiration_date, + total=request.total, + store_note=request.store_note, + store_id=request.store_id, + name=request.name, + module=request.module, + object_id=request.object_id, + ) + for request in result.scalars().all() + ] + + +async def create_request( + request: schemas_mypayment.Request, + db: AsyncSession, +) -> None: + request_db = models_mypayment.Request( + id=request.id, + wallet_id=request.wallet_id, + status=request.status, + creation=request.creation, + total=request.total, + store_note=request.store_note, + store_id=request.store_id, + name=request.name, + module=request.module, + transaction_id=request.transaction_id, + object_id=request.object_id, + ) + db.add(request_db) + + +async def update_request( + request_id: UUID, + request_update: schemas_mypayment.RequestEdit, + db: AsyncSession, +) -> None: + await db.execute( + update(models_mypayment.Request) + .where(models_mypayment.Request.id == request_id) + .values(**request_update.model_dump(exclude_none=True)), + ) + + +async def delete_request( + request_id: UUID, + db: AsyncSession, +) -> None: + await db.execute( + delete(models_mypayment.Request).where( + models_mypayment.Request.id == request_id, + ), + ) + + async def get_store( store_id: UUID, db: AsyncSession, diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index b9339927b7..c50cc941b7 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -4,10 +4,10 @@ import urllib.parse import uuid from datetime import UTC, datetime, timedelta -from pathlib import Path from uuid import UUID import calypsso +from anyio import Path from fastapi import ( APIRouter, BackgroundTasks, @@ -21,65 +21,82 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.auth import schemas_auth +from app.core.checkout import schemas_checkout +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.checkout.types_checkout import HelloAssoConfigName +from app.core.checkout.utils_checkout import CHECKOUT_EXPIRATION from app.core.core_endpoints import cruds_core from app.core.groups.groups_type import GroupType from app.core.memberships.utils_memberships import ( get_user_active_membership_to_association_membership, ) from app.core.mypayment import cruds_mypayment, schemas_mypayment -from app.core.mypayment.coredata_mypayment import MyPaymentBankAccountHolder +from app.core.mypayment.coredata_mypayment import ( + MyPaymentBankAccountHolder, +) from app.core.mypayment.dependencies_mypayment import is_user_bank_account_holder from app.core.mypayment.exceptions_mypayment import ( InvoiceNotFoundAfterCreationError, ReferencedStructureNotFoundError, + UnexpectedError, ) from app.core.mypayment.factory_mypayment import MyPaymentFactory from app.core.mypayment.integrity_mypayment import ( format_cancel_log, format_refund_log, - format_transaction_log, format_withdrawal_log, ) from app.core.mypayment.models_mypayment import Store, WalletDevice from app.core.mypayment.types_mypayment import ( + LATEST_TOS, + MYPAYMENT_DEVICES_S3_SUBFOLDER, + MYPAYMENT_LOGS_S3_SUBFOLDER, + MYPAYMENT_ROOT, + MYPAYMENT_STORES_S3_SUBFOLDER, + MYPAYMENT_STRUCTURE_S3_SUBFOLDER, + MYPAYMENT_USERS_S3_SUBFOLDER, + QRCODE_EXPIRATION, + RETENTION_DURATION, + HistoryDirection, HistoryType, + RequestStatus, + RequestType, TransactionStatus, TransactionType, + TransferOrigin, TransferType, - UnexpectedError, WalletDeviceStatus, WalletType, ) from app.core.mypayment.utils.data_exporter import generate_store_history_csv -from app.core.mypayment.utils.schema_converters import structure_model_to_schema +from app.core.mypayment.utils.models_converter import structure_model_to_schema from app.core.mypayment.utils_mypayment import ( - LATEST_TOS, - QRCODE_EXPIRATION, + apply_transaction, + call_mypayment_callback, is_user_latest_tos_signed, validate_transfer_callback, verify_signature, ) from app.core.notification.schemas_notification import Message -from app.core.payment import schemas_payment -from app.core.payment.payment_tool import PaymentTool -from app.core.payment.types_payment import HelloAssoConfigName +from app.core.permissions.type_permissions import ModulePermissions from app.core.users import cruds_users, schemas_users from app.core.users.models_users import CoreUser from app.core.utils import security from app.core.utils.config import Settings from app.dependencies import ( + get_checkout_tool, get_db, get_mail_templates, get_notification_tool, - get_payment_tool, get_request_id, get_settings, get_token_data, is_user, - is_user_an_ecl_member, + is_user_allowed_to, is_user_in, ) from app.types import standard_responses +from app.types.exceptions import ObjectExpectedInDbNotFoundError from app.types.module import CoreModule from app.types.scopes_type import ScopeType from app.utils.auth.auth_utils import get_user_id_from_token_with_scopes @@ -95,12 +112,18 @@ router = APIRouter(tags=["MyPayment"]) + +class MyPaymentPermissions(ModulePermissions): + access_mypayment = "access_mypayment" + + core_module = CoreModule( - root="mypayment", + root=MYPAYMENT_ROOT, tag="MyPayment", router=router, - payment_callback=validate_transfer_callback, + checkout_callback=validate_transfer_callback, factory=MyPaymentFactory(), + permissions=MyPaymentPermissions, ) @@ -108,13 +131,6 @@ hyperion_security_logger = logging.getLogger("hyperion.security") hyperion_mypayment_logger = logging.getLogger("hyperion.mypayment") -MYPAYMENT_STRUCTURE_S3_SUBFOLDER = "structures" -MYPAYMENT_STORES_S3_SUBFOLDER = "stores" -MYPAYMENT_USERS_S3_SUBFOLDER = "users" -MYPAYMENT_DEVICES_S3_SUBFOLDER = "devices" -MYPAYMENT_LOGS_S3_SUBFOLDER = "logs" -RETENTION_DURATION = 10 * 365 # 10 years in days - @router.get( "/mypayment/bank-account-holder", @@ -226,12 +242,13 @@ async def create_structure( name=structure.name, association_membership_id=structure.association_membership_id, manager_user_id=structure.manager_user_id, + siret=structure.siret, + iban=structure.iban, + bic=structure.bic, siege_address_street=structure.siege_address_street, - siege_address_zipcode=structure.siege_address_zipcode, siege_address_city=structure.siege_address_city, + siege_address_zipcode=structure.siege_address_zipcode, siege_address_country=structure.siege_address_country, - iban=structure.iban, - bic=structure.bic, creation=datetime.now(tz=UTC), ) await cruds_mypayment.create_structure( @@ -397,6 +414,7 @@ async def create_structure_administrator( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, db=db, ) else: @@ -551,7 +569,7 @@ async def init_transfer_structure_manager( background_tasks.add_task( send_email, recipient=user.email, - subject="MyECL - Confirm the structure manager transfer", + subject=f"{settings.school.application_name} - Confirm the structure manager transfer", content=mail, settings=settings, ) @@ -617,6 +635,7 @@ async def confirm_structure_manager_transfer( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, db=db, ) else: @@ -715,6 +734,7 @@ async def create_store( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, db=db, ) for admin in structure.administrators: @@ -725,6 +745,7 @@ async def create_store( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, db=db, ) @@ -800,11 +821,17 @@ async def get_store_history( creation=transaction.refund.creation, ) + if transaction.transaction_type == TransactionType.DIRECT: + transaction_type = HistoryType.DIRECT_TRANSACTION + else: + transaction_type = HistoryType.REQUEST_TRANSACTION + if transaction.debited_wallet_id == store.wallet_id: history.append( schemas_mypayment.History( id=transaction.id, - type=HistoryType.GIVEN, + type=transaction_type, + direction=HistoryDirection.DEBITED, total=transaction.total, status=transaction.status, creation=transaction.creation, @@ -818,7 +845,8 @@ async def get_store_history( history.append( schemas_mypayment.History( id=transaction.id, - type=HistoryType.RECEIVED, + type=transaction_type, + direction=HistoryDirection.CREDITED, total=transaction.total, status=transaction.status, creation=transaction.creation, @@ -835,9 +863,32 @@ async def get_store_history( start_datetime=start_date, end_datetime=end_date, ) - if len(transfers) > 0: - hyperion_error_logger.error( - f"Store {store.id} should never have transfers", + for transfer in transfers: + if transfer.confirmed: + status = TransactionStatus.CONFIRMED + elif datetime.now(UTC) < transfer.creation + timedelta( + minutes=CHECKOUT_EXPIRATION, + ): + status = TransactionStatus.PENDING + else: + status = TransactionStatus.CANCELED + + transfer_type = ( + HistoryType.DIRECT_TRANSFER + if transfer.type == TransferType.DIRECT + else HistoryType.REQUEST_TRANSFER + ) + + history.append( + schemas_mypayment.History( + id=transfer.id, + type=transfer_type, + direction=HistoryDirection.CREDITED, + other_wallet_name="Transfer", + total=transfer.total, + creation=transfer.creation, + status=status, + ), ) # We add refunds @@ -849,16 +900,19 @@ async def get_store_history( ) for refund in refunds: if refund.debited_wallet_id == store.wallet_id: - transaction_type = HistoryType.REFUND_DEBITED + transaction_type = HistoryType.REFUND + direction = HistoryDirection.DEBITED other_wallet_info = refund.credited_wallet else: - transaction_type = HistoryType.REFUND_CREDITED + transaction_type = HistoryType.REFUND + direction = HistoryDirection.CREDITED other_wallet_info = refund.debited_wallet history.append( schemas_mypayment.History( id=refund.id, type=transaction_type, + direction=direction, other_wallet_name=other_wallet_info.owner_name or "Unknown", total=refund.total, creation=refund.creation, @@ -914,18 +968,12 @@ async def export_store_history( ) ) - transfers_with_sellers = ( - await cruds_mypayment.get_transfers_and_sellers_by_wallet_id( - wallet_id=store.wallet_id, - db=db, - start_datetime=start_date, - end_datetime=end_date, - ) + direct_transfers = await cruds_mypayment.get_transfers_by_wallet_id( + wallet_id=store.wallet_id, + db=db, + start_datetime=start_date, + end_datetime=end_date, ) - if len(transfers_with_sellers) > 0: - hyperion_error_logger.error( - f"Store {store.id} should never have transfers", - ) # We add refunds refunds_with_sellers = await cruds_mypayment.get_refunds_and_sellers_by_wallet_id( @@ -945,6 +993,7 @@ async def export_store_history( csv_content = generate_store_history_csv( transactions_with_sellers=list(transactions_with_sellers), refunds_map=refunds_map, + direct_transfers=direct_transfers, store_wallet_id=store.wallet_id, ) @@ -983,7 +1032,9 @@ async def export_store_history( ) async def get_user_stores( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), ): """ Get all stores for the current user. @@ -1206,6 +1257,7 @@ async def create_store_seller( can_see_history=seller.can_see_history, can_cancel=seller.can_cancel, can_manage_sellers=seller.can_manage_sellers, + can_manage_events=seller.can_manage_events, db=db, ) @@ -1413,12 +1465,14 @@ async def delete_store_seller( ) async def register_user( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), ): """ - Sign MyECL Pay TOS for the given user. + Sign MyPayment TOS for the given user. - The user will need to accept the latest TOS version to be able to use MyECL Pay. + The user will need to accept the latest TOS version to be able to use MyPayment. **The user must be authenticated to use this endpoint** """ @@ -1431,7 +1485,7 @@ async def register_user( if existing_user_payment is not None: raise HTTPException( status_code=400, - detail="User is already registered for MyECL Pay", + detail="User is already registered for MyPayment", ) # Create new wallet for user @@ -1487,7 +1541,9 @@ async def patch_payment_identity_in_text( ) async def get_user_tos( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), settings: Settings = Depends(get_settings), ): """ @@ -1503,14 +1559,14 @@ async def get_user_tos( if existing_user_payment is None: raise HTTPException( status_code=400, - detail="User is not registered for MyECL Pay", + detail="User is not registered for MyPayment", ) return schemas_mypayment.TOSSignatureResponse( accepted_tos_version=existing_user_payment.accepted_tos_version, latest_tos_version=LATEST_TOS, tos_content=await patch_payment_identity_in_text( - Path("assets/mypayment-terms-of-service.txt").read_text("utf-8"), + await Path("assets/mypayment-terms-of-service.txt").read_text("utf-8"), settings, user, db, @@ -1527,12 +1583,14 @@ async def sign_tos( signature: schemas_mypayment.TOSSignature, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), mail_templates: calypsso.MailTemplates = Depends(get_mail_templates), settings: Settings = Depends(get_settings), ): """ - Sign MyECL Pay TOS for the given user. + Sign MyPayment TOS for the given user. If the user is already registered in the MyPayment system, this will update the TOS version. @@ -1552,7 +1610,7 @@ async def sign_tos( if existing_user_payment is None: raise HTTPException( status_code=400, - detail="User is not registered for MyECL Pay", + detail="User is not registered for MyPayment", ) # Update existing user payment @@ -1576,7 +1634,7 @@ async def sign_tos( background_tasks.add_task( send_email, recipient=user.email, - subject="MyECL - You signed the Terms of Service for MyPayment", + subject=f"{settings.school.application_name} - You signed the Terms of Service for {settings.school.payment_name}", content=mail, settings=settings, ) @@ -1589,7 +1647,9 @@ async def sign_tos( ) async def get_user_devices( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), ): """ Get user devices. @@ -1604,7 +1664,7 @@ async def get_user_devices( if user_payment is None: raise HTTPException( status_code=400, - detail="User is not registered for MyECL Pay", + detail="User is not registered for MyPayment", ) return await cruds_mypayment.get_wallet_devices_by_wallet_id( @@ -1621,7 +1681,9 @@ async def get_user_devices( async def get_user_device( wallet_device_id: UUID, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), ): """ Get user devices. @@ -1636,7 +1698,7 @@ async def get_user_device( if user_payment is None: raise HTTPException( status_code=400, - detail="User is not registered for MyECL Pay", + detail="User is not registered for MyPayment", ) wallet_device = await cruds_mypayment.get_wallet_device( @@ -1666,7 +1728,9 @@ async def get_user_device( ) async def get_user_wallet( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), ): """ Get user wallet. @@ -1681,7 +1745,7 @@ async def get_user_wallet( if user_payment is None: raise HTTPException( status_code=400, - detail="User is not registered for MyECL Pay", + detail="User is not registered for MyPayment", ) wallet = await cruds_mypayment.get_wallet( @@ -1707,7 +1771,9 @@ async def create_user_devices( wallet_device_creation: schemas_mypayment.WalletDeviceCreation, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), mail_templates: calypsso.MailTemplates = Depends(get_mail_templates), settings: Settings = Depends(get_settings), ): @@ -1725,7 +1791,7 @@ async def create_user_devices( if user_payment is None or not is_user_latest_tos_signed(user_payment): raise HTTPException( status_code=400, - detail="User is not registered for MyECL Pay", + detail="User is not registered for MyPayment", ) activation_token = security.generate_token(nbytes=16) @@ -1755,7 +1821,7 @@ async def create_user_devices( background_tasks.add_task( send_email, recipient=user.email, - subject="MyECL - activate your device", + subject=f"{settings.school.application_name} - activate your {settings.school.payment_name} device", content=mail, settings=settings, ) @@ -1873,7 +1939,7 @@ async def revoke_user_devices( if user_payment is None or not is_user_latest_tos_signed(user_payment): raise HTTPException( status_code=400, - detail="User is not registered for MyECL Pay", + detail="User is not registered for MyPayment", ) wallet_device = await cruds_mypayment.get_wallet_device( @@ -1920,7 +1986,9 @@ async def revoke_user_devices( ) async def get_user_wallet_history( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), start_date: datetime | None = None, end_date: datetime | None = None, ): @@ -1937,7 +2005,7 @@ async def get_user_wallet_history( if user_payment is None: raise HTTPException( status_code=404, - detail="User is not registered for MyECL Pay", + detail="User is not registered for MyPayment", ) history: list[schemas_mypayment.History] = [] @@ -1951,13 +2019,19 @@ async def get_user_wallet_history( ) for transaction in transactions: + if transaction.transaction_type == TransactionType.DIRECT: + transaction_type = HistoryType.DIRECT_TRANSACTION + elif transaction.transaction_type == TransactionType.REQUEST: + transaction_type = HistoryType.REQUEST_TRANSACTION + else: + raise UnexpectedError("Unknown transaction type") # noqa: TRY003 if transaction.credited_wallet_id == user_payment.wallet_id: # The user received the transaction - transaction_type = HistoryType.RECEIVED + direction = HistoryDirection.CREDITED other_wallet = transaction.debited_wallet else: # The user sent the transaction - transaction_type = HistoryType.GIVEN + direction = HistoryDirection.DEBITED other_wallet = transaction.credited_wallet # We need to find if the other wallet correspond to a store or a user to get its display name @@ -1978,6 +2052,7 @@ async def get_user_wallet_history( schemas_mypayment.History( id=transaction.id, type=transaction_type, + direction=direction, other_wallet_name=other_wallet_name, total=transaction.total, creation=transaction.creation, @@ -1997,15 +2072,24 @@ async def get_user_wallet_history( for transfer in transfers: if transfer.confirmed: status = TransactionStatus.CONFIRMED - elif datetime.now(UTC) < transfer.creation + timedelta(minutes=15): + elif datetime.now(UTC) < transfer.creation + timedelta( + minutes=CHECKOUT_EXPIRATION, + ): status = TransactionStatus.PENDING else: status = TransactionStatus.CANCELED + transfer_type = ( + HistoryType.DIRECT_TRANSFER + if transfer.type == TransferType.DIRECT + else HistoryType.REQUEST_TRANSFER + ) + history.append( schemas_mypayment.History( id=transfer.id, - type=HistoryType.TRANSFER, + type=transfer_type, + direction=HistoryDirection.CREDITED, other_wallet_name="Transfer", total=transfer.total, creation=transfer.creation, @@ -2022,16 +2106,17 @@ async def get_user_wallet_history( ) for refund in refunds: if refund.debited_wallet_id == user_payment.wallet_id: - transaction_type = HistoryType.REFUND_DEBITED + direction = HistoryDirection.DEBITED other_wallet_info = refund.credited_wallet else: - transaction_type = HistoryType.REFUND_CREDITED + direction = HistoryDirection.CREDITED other_wallet_info = refund.debited_wallet history.append( schemas_mypayment.History( id=refund.id, - type=transaction_type, + type=HistoryType.REFUND, + direction=direction, other_wallet_name=other_wallet_info.owner_name or "Unknown", total=refund.total, creation=refund.creation, @@ -2044,16 +2129,18 @@ async def get_user_wallet_history( @router.post( "/mypayment/transfer/init", - response_model=schemas_payment.PaymentUrl, + response_model=schemas_checkout.PaymentUrl, status_code=201, ) async def init_ha_transfer( transfer_info: schemas_mypayment.TransferInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), settings: Settings = Depends(get_settings), - payment_tool: PaymentTool = Depends( - get_payment_tool(HelloAssoConfigName.MYPAYMENT), + checkout_tool: CheckoutTool = Depends( + get_checkout_tool(HelloAssoConfigName.MYPAYMENT), ), ): """ @@ -2082,7 +2169,7 @@ async def init_ha_transfer( if user_payment is None: raise HTTPException( status_code=404, - detail="User is not registered for MyECL Pay", + detail="User is not registered for MyPayment", ) if not is_user_latest_tos_signed(user_payment): @@ -2124,8 +2211,8 @@ async def init_ha_transfer( firstname=user.firstname, nickname=user.nickname, ) - checkout = await payment_tool.init_checkout( - module="mypayment", + checkout = await checkout_tool.init_checkout( + module=core_module.root, checkout_amount=transfer_info.amount, checkout_name=f"Recharge {settings.school.payment_name}", redirection_uri=f"{settings.CLIENT_URL}mypayment/transfer/redirect?url={transfer_info.redirect_url}", @@ -2137,24 +2224,27 @@ async def init_ha_transfer( db=db, transfer=schemas_mypayment.Transfer( id=uuid.uuid4(), - type=TransferType.HELLO_ASSO, + type=TransferType.DIRECT, + origin=TransferOrigin.HELLO_ASSO, approver_user_id=None, total=transfer_info.amount, transfer_identifier=str(checkout.id), wallet_id=user_payment.wallet_id, creation=datetime.now(UTC), confirmed=False, + module=None, + object_id=None, ), ) - return schemas_payment.PaymentUrl( + return schemas_checkout.PaymentUrl( url=checkout.payment_url, ) @router.get( "/mypayment/transfer/redirect", - response_model=schemas_payment.PaymentUrl, + response_model=schemas_checkout.PaymentUrl, status_code=201, ) async def redirect_from_ha_transfer( @@ -2205,7 +2295,7 @@ async def validate_can_scan_qrcode( store_id: UUID, scan_info: schemas_mypayment.ScanInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_an_ecl_member), + user: CoreUser = Depends(is_user()), ): """ Validate if a given QR Code can be scanned by the seller. @@ -2292,7 +2382,7 @@ async def store_scan_qrcode( store_id: UUID, scan_info: schemas_mypayment.ScanInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_an_ecl_member), + user: CoreUser = Depends(is_user()), request_id: str = Depends(get_request_id), notification_tool: NotificationTool = Depends(get_notification_tool), settings: Settings = Depends(get_settings), @@ -2471,56 +2561,26 @@ async def store_scan_qrcode( detail="User is not a member of the association", ) - # We increment the receiving wallet balance - await cruds_mypayment.increment_wallet_balance( - wallet_id=store.wallet_id, - amount=scan_info.tot, - db=db, - ) - - # We decrement the debited wallet balance - await cruds_mypayment.increment_wallet_balance( - wallet_id=debited_wallet.id, - amount=-scan_info.tot, - db=db, - ) - transaction_id = uuid.uuid4() - creation_date = datetime.now(UTC) transaction = schemas_mypayment.TransactionBase( - id=transaction_id, + id=uuid.uuid4(), debited_wallet_id=debited_wallet_device.wallet_id, credited_wallet_id=store.wallet_id, transaction_type=TransactionType.DIRECT, seller_user_id=user.id, total=scan_info.tot, - creation=creation_date, + creation=datetime.now(UTC), status=TransactionStatus.CONFIRMED, qr_code_id=scan_info.id, ) - # We create a transaction - await cruds_mypayment.create_transaction( + await apply_transaction( + user_id=debited_wallet.user.id, + debited_wallet_device=debited_wallet_device, + store=store, transaction=transaction, - debited_wallet_device_id=debited_wallet_device.id, - store_note=None, db=db, + notification_tool=notification_tool, ) - hyperion_mypayment_logger.info( - format_transaction_log(transaction), - extra={ - "s3_subfolder": MYPAYMENT_LOGS_S3_SUBFOLDER, - "s3_retention": RETENTION_DURATION, - }, - ) - message = Message( - title=f"💳 Paiement - {store.name}", - content=f"Une transaction de {scan_info.tot / 100} € a été effectuée", - action_module=settings.school.payment_name, - ) - await notification_tool.send_notification_to_user( - user_id=debited_wallet.user.id, - message=message, - ) return transaction @@ -2532,7 +2592,7 @@ async def refund_transaction( transaction_id: UUID, refund_info: schemas_mypayment.RefundInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_an_ecl_member), + user: CoreUser = Depends(is_user()), notification_tool: NotificationTool = Depends(get_notification_tool), settings: Settings = Depends(get_settings), ): @@ -2702,6 +2762,7 @@ async def refund_transaction( ) if wallet_previously_credited.user is not None: + wallet_previously_debited_name: str = "Unknown" if wallet_previously_debited.user is not None: wallet_previously_debited_name = wallet_previously_debited.user.full_name elif wallet_previously_debited.store is not None: @@ -2724,7 +2785,7 @@ async def refund_transaction( async def cancel_transaction( transaction_id: UUID, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_an_ecl_member), + user: CoreUser = Depends(is_user()), request_id: str = Depends(get_request_id), notification_tool: NotificationTool = Depends(get_notification_tool), settings: Settings = Depends(get_settings), @@ -2853,6 +2914,270 @@ async def cancel_transaction( ) +@router.get( + "/mypayment/requests", + response_model=list[schemas_mypayment.Request], +) +async def get_user_requests( + used: bool | None = None, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), +): + """ + Get all requests made by the user. + + **The user must be authenticated to use this endpoint** + """ + user_payment = await cruds_mypayment.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None: + raise HTTPException( + status_code=404, + detail="User is not registered for MyPayment", + ) + return await cruds_mypayment.get_requests_by_wallet_id( + wallet_id=user_payment.wallet_id, + db=db, + include_used=used or False, + ) + + +@router.post( + "/mypayment/requests/{request_id}/accept", + status_code=204, +) +async def accept_request( + request_id: UUID, + request_validation: schemas_mypayment.SignedContent, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), + http_request_id: str = Depends(get_request_id), + notification_tool: NotificationTool = Depends(get_notification_tool), + settings: Settings = Depends(get_settings), +): + """ + Confirm a request. + + **The user must be authenticated to use this endpoint** + """ + if request_id != request_validation.id: + raise HTTPException( + status_code=400, + detail="Request ID in the path and in the body do not match", + ) + request = await cruds_mypayment.get_request_by_id( + request_id=request_id, + db=db, + ) + if request is None: + raise HTTPException( + status_code=404, + detail="Request does not exist", + ) + if request.total != request_validation.tot: + raise HTTPException( + status_code=400, + detail="Request total in the body do not match the request total in the database", + ) + if request.status != RequestStatus.PROPOSED: + raise HTTPException( + status_code=400, + detail="Only pending requests can be confirmed", + ) + if request.expiration_date < datetime.now(UTC): + raise HTTPException( + status_code=400, + detail="Request is expired", + ) + + user_payment = await cruds_mypayment.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None: + raise HTTPException( + status_code=404, + detail="User is not registered for MyPayment", + ) + + if request.wallet_id != user_payment.wallet_id: + raise HTTPException( + status_code=403, + detail="User is not allowed to confirm this request", + ) + + debited_wallet_device = await cruds_mypayment.get_wallet_device( + wallet_device_id=request_validation.key, + db=db, + ) + if debited_wallet_device is None: + raise HTTPException( + status_code=404, + detail="Wallet device does not exist", + ) + if debited_wallet_device.wallet_id != user_payment.wallet_id: + raise HTTPException( + status_code=400, + detail="Wallet device is not associated with the user wallet", + ) + + if not verify_signature( + public_key_bytes=debited_wallet_device.ed25519_public_key, + signature=request_validation.signature, + data=request_validation, + wallet_device_id=request_validation.key, + request_id=http_request_id, + ): + raise HTTPException( + status_code=400, + detail="Invalid signature", + ) + + # We verify that the debited walled contains enough money + debited_wallet = await cruds_mypayment.get_wallet( + wallet_id=debited_wallet_device.wallet_id, + db=db, + ) + if debited_wallet is None: + hyperion_error_logger.error( + f"MyPayment: Could not find wallet associated with the debited wallet device {debited_wallet_device.id}, this should never happen", + ) + raise HTTPException( + status_code=400, + detail="Could not find wallet associated with the debited wallet device", + ) + if debited_wallet.user is None or debited_wallet.store is not None: + raise HTTPException( + status_code=400, + detail="Wrong type of wallet's owner", + ) + + debited_user_payment = await cruds_mypayment.get_user_payment( + debited_wallet.user.id, + db=db, + ) + if debited_user_payment is None or not is_user_latest_tos_signed( + debited_user_payment, + ): + raise HTTPException( + status_code=400, + detail="Debited user has not signed the latest TOS", + ) + + if debited_wallet.balance < request_validation.tot: + raise HTTPException( + status_code=400, + detail="Insufficient balance in the debited wallet", + ) + + store = await cruds_mypayment.get_store( + store_id=request.store_id, + db=db, + ) + if store is None: + raise ObjectExpectedInDbNotFoundError( + object_name="Store", + object_id=request.store_id, + ) + + transaction = schemas_mypayment.TransactionBase( + id=uuid.uuid4(), + debited_wallet_id=debited_wallet_device.wallet_id, + credited_wallet_id=store.wallet_id, + transaction_type=TransactionType.REQUEST, + seller_user_id=user.id, + total=request_validation.tot, + creation=datetime.now(UTC), + status=TransactionStatus.CONFIRMED, + qr_code_id=None, + ) + await apply_transaction( + transaction=transaction, + debited_wallet_device=debited_wallet_device, + user_id=user.id, + db=db, + notification_tool=notification_tool, + store=store, + ) + + await cruds_mypayment.update_request( + request_id=request_id, + request_update=schemas_mypayment.RequestEdit( + status=RequestStatus.ACCEPTED, + transaction_id=transaction.id, + ), + db=db, + ) + await call_mypayment_callback( + call_type=RequestType.TRANSACTION_REQUEST, + module_root=request.module, + object_id=request.object_id, + call_id=request.id, + db=db, + ) + + +@router.post( + "/mypayment/requests/{request_id}/refuse", + status_code=204, +) +async def refuse_request( + request_id: UUID, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends( + is_user_allowed_to([MyPaymentPermissions.access_mypayment]), + ), +): + """ + Refuse a request. + + **The user must be authenticated to use this endpoint** + """ + request = await cruds_mypayment.get_request_by_id( + request_id=request_id, + db=db, + ) + if request is None: + raise HTTPException( + status_code=404, + detail="Request does not exist", + ) + + user_payment = await cruds_mypayment.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None: + raise HTTPException( + status_code=404, + detail="User is not registered for MyPayment", + ) + + if request.wallet_id != user_payment.wallet_id: + raise HTTPException( + status_code=403, + detail="User is not allowed to refuse this request", + ) + + if request.status != RequestStatus.PROPOSED: + raise HTTPException( + status_code=400, + detail="Only pending requests can be refused", + ) + + await cruds_mypayment.update_request( + request_id=request_id, + request_update=schemas_mypayment.RequestEdit(status=RequestStatus.REFUSED), + db=db, + ) + + @router.get( "/mypayment/invoices", response_model=list[schemas_mypayment.Invoice], @@ -2977,8 +3302,8 @@ async def download_invoice( async def create_structure_invoice( structure_id: UUID, db: AsyncSession = Depends(get_db), - token_data: schemas_auth.TokenData = Depends(get_token_data), settings: Settings = Depends(get_settings), + token_data: schemas_auth.TokenData = Depends(get_token_data), ): """ Create an invoice for a structure. @@ -2992,15 +3317,10 @@ async def create_structure_invoice( scopes=[[ScopeType.API]], token_data=token_data, ) - user = await cruds_users.get_user_by_id( - db=db, - user_id=user_id, - ) + user = await cruds_users.get_user_by_id(db, user_id) if user is None: - raise HTTPException( - status_code=404, - detail="User does not exist", - ) + raise HTTPException(404, "User not found in the database") + bank_holder_structure = await get_bank_account_holder( user=user, db=db, @@ -3045,9 +3365,9 @@ async def create_structure_invoice( hyperion_error_logger.error( "MyPayment: Could not find wallet associated with a store, this should never happen", ) - raise HTTPException( - status_code=500, - detail="Could not find wallet associated with the store", + raise ObjectExpectedInDbNotFoundError( + object_name="Wallet", + object_id=store.wallet_id, ) store_wallet = schemas_mypayment.Wallet( id=store_wallet_db.id, @@ -3213,9 +3533,9 @@ async def aknowledge_invoice_as_received( db=db, ) if structure is None: - raise HTTPException( - status_code=500, - detail="Structure does not exist", + raise ObjectExpectedInDbNotFoundError( + object_name="Structure", + object_id=invoice.structure_id, ) if structure.manager_user_id != user.id and user.id not in [ admin.id for admin in structure.administrators @@ -3238,9 +3558,9 @@ async def aknowledge_invoice_as_received( hyperion_error_logger.error( "MyPayment: Could not find store associated with an invoice, this should never happen", ) - raise HTTPException( - status_code=500, - detail="Could not find store associated with the invoice", + raise ObjectExpectedInDbNotFoundError( + object_name="Store", + object_id=detail.store_id, ) await cruds_mypayment.increment_wallet_balance( wallet_id=store.wallet_id, @@ -3310,13 +3630,13 @@ async def delete_structure_invoice( response_model=schemas_mypayment.IntegrityCheckData, ) async def get_data_for_integrity_check( - headers: schemas_mypayment.IntegrityCheckHeaders = Header(), + headers: schemas_mypayment.IntegrityCheckHeaders = Header(...), query_params: schemas_mypayment.IntegrityCheckQuery = Query(), db: AsyncSession = Depends(get_db), settings: Settings = Depends(get_settings), ): """ - Send all the MyECL Pay data for integrity check. + Send all the MyPayment data for integrity check. Data includes: - Wallets deducted of the last 30 seconds transactions - Transactions with at least 30 seconds delay @@ -3327,7 +3647,7 @@ async def get_data_for_integrity_check( """ if settings.MYPAYMENT_DATA_VERIFIER_ACCESS_TOKEN is None: raise HTTPException( - status_code=301, + status_code=401, detail="MYPAYMENT_DATA_VERIFIER_ACCESS_TOKEN is not set in the settings", ) diff --git a/app/core/mypayment/exceptions_mypayment.py b/app/core/mypayment/exceptions_mypayment.py index 1d639cc02d..990bbf8eee 100644 --- a/app/core/mypayment/exceptions_mypayment.py +++ b/app/core/mypayment/exceptions_mypayment.py @@ -1,5 +1,8 @@ from uuid import UUID +from app.core.checkout.types_checkout import HelloAssoConfigName +from app.core.mypayment.types_mypayment import RequestType, WalletType + class WalletNotFoundOnUpdateError(Exception): """ @@ -11,6 +14,28 @@ def __init__(self, wallet_id: UUID): super().__init__(f"Wallet {wallet_id} not found when updating") +class LinkedWalletNotFoundError(Exception): + """ + Exception raised when a wallet linked to a transaction is not found. + This should lead to an internal server error response and a rollback of the transaction. + """ + + def __init__(self, wallet_id: UUID): + super().__init__(f"Linked wallet {wallet_id} not found for transaction") + + +class InvalidWalletTypeError(Exception): + """ + Exception raised when a wallet has an invalid type for the operation being performed. + This should lead to an internal server error response and a rollback of the transaction. + """ + + def __init__(self, wallet_id: UUID, expected_type: WalletType): + super().__init__( + f"Wallet {wallet_id} has an invalid type. Expected type: {expected_type.name}", + ) + + class InvoiceNotFoundAfterCreationError(Exception): """ Exception raised when an invoice is not found after its creation. @@ -29,3 +54,68 @@ class ReferencedStructureNotFoundError(Exception): def __init__(self, structure_id: UUID): super().__init__(f"Referenced structure {structure_id} not found") + + +class UnexpectedError(Exception): + pass + + +class TransferNotFoundByCallbackError(Exception): + def __init__(self, checkout_id: UUID): + super().__init__(f"User transfer {checkout_id} not found.") + + +class TransferTotalDontMatchInCallbackError(Exception): + def __init__(self, transfer_id: UUID): + super().__init__( + f"User transfer {transfer_id} amount does not match the paid amount", + ) + + +class TransferAlreadyConfirmedInCallbackError(Exception): + def __init__(self, transfer_id: UUID): + super().__init__( + f"User transfer {transfer_id} has already been confirmed", + ) + + +class PaymentUserNotFoundError(Exception): + def __init__(self, user_id: str): + super().__init__( + f"User {user_id} does not have a payment account", + ) + + +class InvalidRequestTypeError(Exception): + def __init__(self, request_type: RequestType): + super().__init__( + f"Request type {request_type.name} is not supported", + ) + + +class InvalidCheckoutToolError(Exception): + def __init__(self, name: HelloAssoConfigName): + super().__init__( + f"Checkout tool {name.name} is not supported", + ) + + +class UnlinkedValidatedRequestError(Exception): + def __init__(self, request_id: UUID): + super().__init__( + f"Request {request_id} has been validated but is not linked to any transfer or transaction", + ) + + +class PaiementObjectNotFoundError(Exception): + def __init__(self, object_id: UUID): + super().__init__( + f"Payment object with id {object_id} not found", + ) + + +class InvalidRefundOnTransferError(Exception): + def __init__(self, transfer_id: UUID): + super().__init__( + f"Transfer {transfer_id} cannot be refunded as it does not have an approver_user_id", + ) diff --git a/app/core/mypayment/factory_mypayment.py b/app/core/mypayment/factory_mypayment.py index caa5aaaadc..dec2297102 100644 --- a/app/core/mypayment/factory_mypayment.py +++ b/app/core/mypayment/factory_mypayment.py @@ -33,7 +33,8 @@ async def create_structures(cls, db: AsyncSession): schemas_mypayment.StructureSimple( id=uuid.uuid4(), short_id="".join(faker.random_letters(3)).upper(), - name=CoreUsersFactory.demo_users[i].nickname or "", + name=CoreUsersFactory.demo_users[i].nickname + or CoreUsersFactory.demo_users[i].name, manager_user_id=user_id, siege_address_street=faker.street_address(), siege_address_city=faker.city(), diff --git a/app/core/mypayment/integrity_mypayment.py b/app/core/mypayment/integrity_mypayment.py index 78a3262dfb..e41abfd4f7 100644 --- a/app/core/mypayment/integrity_mypayment.py +++ b/app/core/mypayment/integrity_mypayment.py @@ -21,19 +21,19 @@ def format_transfer_log( transfer: schemas_mypayment.Transfer | models_mypayment.Transfer, -): - return f"{ActionType.TRANSFER.name} {transfer.id} {transfer.type.name} {transfer.total} {transfer.wallet_id}" +) -> str: + return f"{ActionType.TRANSFER.name} {transfer.id} {transfer.origin.name} {transfer.total} {transfer.wallet_id}" def format_transaction_log( transaction: schemas_mypayment.TransactionBase, -): +) -> str: return f"{ActionType.TRANSACTION.name} {transaction.id} {transaction.debited_wallet_id} {transaction.credited_wallet_id} {transaction.total}" def format_refund_log( refund: schemas_mypayment.RefundBase, -): +) -> str: return ( f"{ActionType.REFUND.name} {refund.id} {refund.transaction_id} {refund.total}" ) @@ -41,19 +41,19 @@ def format_refund_log( def format_cancel_log( transaction_id: UUID, -): +) -> str: return f"{ActionType.CANCEL.name} {transaction_id}" def format_withdrawal_log( wallet_id: UUID, total: int, -): +) -> str: return f"{ActionType.WITHDRAWAL.name} {wallet_id} {total}" def format_user_fusion_log( user_kept_id: str, user_deleted_id: str, -): +) -> str: return f"{ActionType.USER_FUSION.name} {user_kept_id} {user_deleted_id}" diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index c33e89b282..36ab1c207f 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from uuid import UUID from sqlalchemy import ForeignKey @@ -6,9 +6,11 @@ from app.core.memberships import models_memberships from app.core.mypayment.types_mypayment import ( + REQUEST_EXPIRATION, RequestStatus, TransactionStatus, TransactionType, + TransferOrigin, TransferType, WalletDeviceStatus, WalletType, @@ -49,7 +51,7 @@ class Transaction(Base): id: Mapped[PrimaryKey] debited_wallet_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_wallet.id")) - debited_wallet_device_id: Mapped[UUID] = mapped_column( + debited_wallet_device_id: Mapped[UUID | None] = mapped_column( ForeignKey("mypayment_wallet_device.id"), ) credited_wallet_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_wallet.id")) @@ -192,25 +194,30 @@ class Request(Base): __tablename__ = "mypayment_request" id: Mapped[PrimaryKey] - wallet_id: Mapped[str] = mapped_column(ForeignKey("mypayment_wallet.id")) + wallet_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_wallet.id")) creation: Mapped[datetime] total: Mapped[int] # Stored in cents - store_id: Mapped[str] = mapped_column(ForeignKey("mypayment_store.id")) + store_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_store.id")) name: Mapped[str] store_note: Mapped[str | None] - callback: Mapped[str] + module: Mapped[str] + object_id: Mapped[UUID] status: Mapped[RequestStatus] transaction_id: Mapped[UUID | None] = mapped_column( ForeignKey("mypayment_transaction.id"), unique=True, ) + @property + def expiration_date(self) -> datetime: + return self.creation + timedelta(minutes=REQUEST_EXPIRATION) + class Transfer(Base): __tablename__ = "mypayment_transfer" id: Mapped[PrimaryKey] - type: Mapped[TransferType] + origin: Mapped[TransferOrigin] transfer_identifier: Mapped[str] # TODO remove if we only accept hello asso @@ -221,6 +228,20 @@ class Transfer(Base): creation: Mapped[datetime] confirmed: Mapped[bool] + # A direct transfer is initiated by the user, for its own wallet. The only situation where a store may + # possess a transfer is when a user ask to pay directly a store using a Checkout method (ex: HelloAsso) during a payment request. + # In this situation the store's wallet is credited with a `request` transfer. + # In this case, we want to keep the information of module and object that initiated the request, + # to be able to call the right callback when the transfer is confirmed + module: Mapped[str | None] + object_id: Mapped[UUID | None] + + @property + def type(self) -> TransferType: + if self.module is not None: + return TransferType.REQUEST + return TransferType.DIRECT + class Seller(Base): __tablename__ = "mypayment_seller" @@ -238,6 +259,8 @@ class Seller(Base): can_cancel: Mapped[bool] can_manage_sellers: Mapped[bool] + can_manage_events: Mapped[bool] + user: Mapped[models_users.CoreUser] = relationship(init=False, lazy="joined") diff --git a/app/core/mypayment/mypayment_tool.py b/app/core/mypayment/mypayment_tool.py new file mode 100644 index 0000000000..083f1d8c56 --- /dev/null +++ b/app/core/mypayment/mypayment_tool.py @@ -0,0 +1,106 @@ +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.mypayment import ( + cruds_mypayment, + schemas_mypayment, + utils_mypayment, +) +from app.core.mypayment.exceptions_mypayment import PaiementObjectNotFoundError +from app.core.mypayment.types_mypayment import ( + RequestType, +) +from app.core.users import schemas_users +from app.core.utils.config import Settings +from app.utils.communication.notifications import NotificationTool + + +class MyPaymentTool: + """ + Utility class to interact with MyPayment core module + + The dependency `get_mypayment_tool` should be used to get an instance of this class, which will ensure that all dependencies are properly injected. + """ + + def __init__( + self, + db: AsyncSession, + checkout_tool: CheckoutTool, + notification_tool: NotificationTool, + settings: Settings, + ): + self.db = db + self.checkout_tool = checkout_tool + self.notification_tool = notification_tool + self.settings = settings + + async def request_payment( + self, + request_type: RequestType, + payment_info: schemas_mypayment.PaymentInfo, + user: schemas_users.CoreUser, + ) -> schemas_mypayment.PaymentRequestInfo: + """ + Initiate a payment request. This request can be either: + - a REQUEST_TRANSFER: a checkout will be instantiated, and be credited directly to the store wallet. + In this case, a `checkout_url` url will be returned, the user should be redirected to this url to complete the checkout. + - a REQUEST_TRANSACTION: when accepted by the user, a transaction will be created between the user wallet and the store wallet. + The user should be redirected to mypayment module, to be asked to accept or refuse the transaction request. + + The request is valid until `end_date` + + The `CheckoutTool` must be a *MyPayment* checkout tool + + Use `get_mypayment_tool` dependency to get an instance of `MyPaymentTool`, which will ensure that all dependencies are properly injected. + + When the request is confirmed (checkout validated or transaction accepted), a callback will be called, with the following signature: + ```python + async def mypayment_callback(object_id: UUID, db: AsyncSession) + ``` + """ + return await utils_mypayment.request_payment( + request_type=request_type, + payment_info=payment_info, + user=user, + db=self.db, + checkout_tool=self.checkout_tool, + notification_tool=self.notification_tool, + settings=self.settings, + ) + + async def refund_payment( + self, + user_id: str, + object_id: UUID, + amount: int, + ) -> None: + """ + Refund a payment. The `payment_id` is the id of the payment to refund, and can be retrieved from the `PaymentRequestInfo` returned by the `request_payment` method. + + Use `get_mypayment_tool` dependency to get an instance of `MyPaymentTool`, which will ensure that all dependencies are properly injected. + """ + request = await cruds_mypayment.get_request_by_object_id( + object_id=object_id, + db=self.db, + ) + if request is not None: + return await utils_mypayment.refund_request( + request=request, + amount=amount, + db=self.db, + notification_tool=self.notification_tool, + ) + transfer = await cruds_mypayment.get_transfer_by_object_id( + object_id=object_id, + db=self.db, + ) + if transfer is not None: + return await utils_mypayment.refund_direct_transfer( + transfer=transfer, + amount=amount, + db=self.db, + notification_tool=self.notification_tool, + ) + raise PaiementObjectNotFoundError(object_id) diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 8cd13dbacf..186a8ef96e 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -9,9 +9,12 @@ from app.core.memberships import schemas_memberships from app.core.mypayment.types_mypayment import ( + HistoryDirection, HistoryType, + RequestStatus, TransactionStatus, TransactionType, + TransferOrigin, TransferType, WalletDeviceStatus, WalletType, @@ -102,6 +105,7 @@ class SellerCreation(BaseModel): can_see_history: bool can_cancel: bool can_manage_sellers: bool + can_manage_events: bool = False class SellerUpdate(BaseModel): @@ -109,6 +113,7 @@ class SellerUpdate(BaseModel): can_see_history: bool | None = None can_cancel: bool | None = None can_manage_sellers: bool | None = None + can_manage_events: bool | None = None class Seller(BaseModel): @@ -119,6 +124,9 @@ class Seller(BaseModel): can_cancel: bool can_manage_sellers: bool + # Event module + can_manage_events: bool + user: schemas_users.CoreUserSimple @@ -149,6 +157,12 @@ class TransferInfo(BaseModel): redirect_url: str +class StoreTransferInfo(TransferInfo): + store_id: UUID + module: str + object_id: UUID + + class RefundInfo(BaseModel): complete_refund: bool amount: int | None = None @@ -162,6 +176,7 @@ class HistoryRefund(BaseModel): class History(BaseModel): id: UUID type: HistoryType + direction: HistoryDirection other_wallet_name: str total: int creation: datetime @@ -169,31 +184,6 @@ class History(BaseModel): refund: HistoryRefund | None = None -class QRCodeContentData(BaseModel): - """ - Format of the data stored in the QR code. - - This data will be signed using ed25519 and the private key of the WalletDevice that generated the QR Code. - - id: Unique identifier of the QR Code - tot: Total amount of the transaction, in cents - iat: Generation datetime of the QR Code - key: Id of the WalletDevice that generated the QR Code, will be used to verify the signature - store: If the QR Code is intended to be scanned for a Store Wallet, or for an other user Wallet - """ - - id: UUID - tot: int - iat: datetime - key: UUID - store: bool - - -class ScanInfo(QRCodeContentData): - signature: str - bypass_membership: bool = False - - class WalletBase(BaseModel): id: UUID type: WalletType @@ -247,9 +237,9 @@ class Transaction(TransactionBase): refund: "RefundBase | None" = None -class Transfer(BaseModel): +class TransferCreation(BaseModel): id: UUID - type: TransferType + origin: TransferOrigin transfer_identifier: str # TODO remove if we only accept hello asso @@ -259,6 +249,12 @@ class Transfer(BaseModel): total: int # Stored in cents creation: datetime confirmed: bool + module: str | None + object_id: UUID | None + + +class Transfer(TransferCreation): + type: TransferType class RefundBase(BaseModel): @@ -348,3 +344,72 @@ class Withdrawal(BaseModel): wallet_id: UUID total: int # Stored in cents creation: datetime + + +class Request(BaseModel): + id: UUID + wallet_id: UUID + creation: datetime + expiration_date: datetime + total: int # Stored in cents + store_id: UUID + name: str + store_note: str | None = None + module: str # module root, will be used to call the payment callback with the provided object_id + object_id: UUID + status: RequestStatus + transaction_id: UUID | None = None + + +class RequestEdit(BaseModel): + name: str | None = None + store_note: str | None = None + status: RequestStatus | None = None + transaction_id: UUID | None = None + + +class RequestInfo(BaseModel): + store_id: UUID + total: int + request_name: str + store_note: str | None + module: str + # Id of the object from the module, this id will be passed to the module in the transaction callback + object_id: UUID + + +class PaymentInfo(RequestInfo): + redirect_url: str + + +class SecuredContentData(BaseModel): + """ + Format of the data stored in the payment order. + + This data will be signed using ed25519 and the private key of the WalletDevice that generated the payment order + + id: Unique identifier of the payment + tot: Total amount of the transaction, in cents + iat: Generation datetime of the payment order + key: Id of the WalletDevice that generated the payment order, will be used to get the public key to verify the signature + store: If the payment is intended to be banked by a store or by an other user + """ + + id: UUID + tot: int + iat: datetime + key: UUID + store: bool + + +class SignedContent(SecuredContentData): + signature: str + + +class ScanInfo(SignedContent): + bypass_membership: bool = False + + +class PaymentRequestInfo(BaseModel): + end_date: datetime + checkout_url: str | None = None diff --git a/app/core/mypayment/types_mypayment.py b/app/core/mypayment/types_mypayment.py index c526982b25..0c788d5c0a 100644 --- a/app/core/mypayment/types_mypayment.py +++ b/app/core/mypayment/types_mypayment.py @@ -1,33 +1,49 @@ -from enum import Enum -from uuid import UUID +from enum import StrEnum +LATEST_TOS = 2 +QRCODE_EXPIRATION = 5 # minutes +REQUEST_EXPIRATION = 8 # minutes +RETENTION_DURATION = 10 * 365 # 10 years in days +MYPAYMENT_ROOT = "mypayment" -class WalletType(str, Enum): +MYPAYMENT_STRUCTURE_S3_SUBFOLDER = "structures" +MYPAYMENT_STORES_S3_SUBFOLDER = "stores" +MYPAYMENT_USERS_S3_SUBFOLDER = "users" +MYPAYMENT_DEVICES_S3_SUBFOLDER = "devices" +MYPAYMENT_LOGS_S3_SUBFOLDER = "logs" + + +class WalletType(StrEnum): USER = "user" STORE = "store" -class WalletDeviceStatus(str, Enum): +class WalletDeviceStatus(StrEnum): INACTIVE = "inactive" ACTIVE = "active" REVOKED = "revoked" -class TransactionType(str, Enum): +class TransactionType(StrEnum): + # Direct correspond to a QR code payment DIRECT = "direct" REQUEST = "request" + + +class HistoryType(StrEnum): REFUND = "refund" + DIRECT_TRANSFER = "direct_transfer" + REQUEST_TRANSFER = "request_transfer" + DIRECT_TRANSACTION = "direct_transaction" + REQUEST_TRANSACTION = "request_transaction" -class HistoryType(str, Enum): - TRANSFER = "transfer" - RECEIVED = "received" - GIVEN = "given" - REFUND_CREDITED = "refund_credited" - REFUND_DEBITED = "refund_debited" +class HistoryDirection(StrEnum): + CREDITED = "credited" + DEBITED = "debited" -class TransactionStatus(str, Enum): +class TransactionStatus(StrEnum): """ CONFIRMED: The transaction has been confirmed and is complete. CANCELED: The transaction has been canceled. It is used for transfer requests, for which the user has 15 minutes to complete the HelloAsso checkout @@ -41,17 +57,25 @@ class TransactionStatus(str, Enum): PENDING = "pending" -class RequestStatus(str, Enum): +class RequestStatus(StrEnum): PROPOSED = "proposed" ACCEPTED = "accepted" REFUSED = "refused" + EXPIRED = "expired" -class TransferType(str, Enum): +class TransferOrigin(StrEnum): HELLO_ASSO = "hello_asso" -class ActionType(str, Enum): +class TransferType(StrEnum): + # The user transfer money to its own wallet + DIRECT = "direct" + # Requests are initiated by the client, who directly transfer the money to the store wallet + REQUEST = "request" + + +class ActionType(StrEnum): TRANSFER = "transfer" REFUND = "refund" CANCEL = "cancel" @@ -60,24 +84,9 @@ class ActionType(str, Enum): USER_FUSION = "user_fusion" -class UnexpectedError(Exception): - pass - - -class TransferNotFoundByCallbackError(Exception): - def __init__(self, checkout_id: UUID): - super().__init__(f"User transfer {checkout_id} not found.") - - -class TransferTotalDontMatchInCallbackError(Exception): - def __init__(self, transfer_id: UUID): - super().__init__( - f"User transfer {transfer_id} amount does not match the paid amount", - ) - - -class TransferAlreadyConfirmedInCallbackError(Exception): - def __init__(self, transfer_id: UUID): - super().__init__( - f"User transfer {transfer_id} has already been confirmed", - ) +class RequestType(StrEnum): + # The user will be redirected to a checkout payment page to complete the transfer + # The total will be directly credited to the store wallet as a *transfer* + TRANSFER_REQUEST = "transfer_request" + # After being accepted by the user, a transaction will be created between the user wallet and the store wallet + TRANSACTION_REQUEST = "transaction_request" diff --git a/app/core/mypayment/utils/data_exporter.py b/app/core/mypayment/utils/data_exporter.py index 42e356b5f3..af0ef8ff70 100644 --- a/app/core/mypayment/utils/data_exporter.py +++ b/app/core/mypayment/utils/data_exporter.py @@ -1,12 +1,14 @@ import csv from io import StringIO +from uuid import UUID -from app.core.mypayment import models_mypayment +from app.core.mypayment import models_mypayment, schemas_mypayment def generate_store_history_csv( transactions_with_sellers: list[tuple[models_mypayment.Transaction, str | None]], - refunds_map: dict, + refunds_map: dict[UUID, tuple[models_mypayment.Refund, str | None]], + direct_transfers: list[schemas_mypayment.Transfer], store_wallet_id, ) -> str: """ @@ -82,6 +84,22 @@ def generate_store_history_csv( ], ) + # Write direct transfers data + for transfer in direct_transfers: + writer.writerow( + [ + transfer.creation.strftime("%d/%m/%Y %H:%M:%S"), + "REÇU", + "HelloAsso", + str(transfer.total / 100), + "CONFIRMÉ" if transfer.confirmed else "EN ATTENTE", + "N/A", + "", + "", + f"Transfert direct pour le module {transfer.module}", + ], + ) + csv_content = csv_io.getvalue() csv_io.close() diff --git a/app/core/mypayment/utils/schema_converters.py b/app/core/mypayment/utils/models_converter.py similarity index 100% rename from app/core/mypayment/utils/schema_converters.py rename to app/core/mypayment/utils/models_converter.py diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 7d29a6162d..574c5737e5 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -1,41 +1,67 @@ import base64 import logging -from uuid import UUID +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric import ed25519 +from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.mypayment import cruds_mypayment +from app.core.checkout import schemas_checkout +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.checkout.types_checkout import HelloAssoConfigName +from app.core.checkout.utils_checkout import CHECKOUT_EXPIRATION +from app.core.mypayment import cruds_mypayment, models_mypayment, schemas_mypayment +from app.core.mypayment.exceptions_mypayment import ( + InvalidCheckoutToolError, + InvalidRefundOnTransferError, + InvalidRequestTypeError, + InvalidWalletTypeError, + LinkedWalletNotFoundError, + PaymentUserNotFoundError, + TransferAlreadyConfirmedInCallbackError, + TransferNotFoundByCallbackError, + TransferTotalDontMatchInCallbackError, + UnlinkedValidatedRequestError, + WalletNotFoundOnUpdateError, +) from app.core.mypayment.integrity_mypayment import ( + format_refund_log, + format_transaction_log, format_transfer_log, format_user_fusion_log, ) from app.core.mypayment.models_mypayment import UserPayment -from app.core.mypayment.schemas_mypayment import ( - QRCodeContentData, -) +from app.core.mypayment.schemas_mypayment import SecuredContentData from app.core.mypayment.types_mypayment import ( - TransferAlreadyConfirmedInCallbackError, - TransferNotFoundByCallbackError, - TransferTotalDontMatchInCallbackError, + LATEST_TOS, + MYPAYMENT_LOGS_S3_SUBFOLDER, + MYPAYMENT_ROOT, + REQUEST_EXPIRATION, + RETENTION_DURATION, + RequestStatus, + RequestType, + TransactionStatus, + TransactionType, + TransferOrigin, + WalletType, ) -from app.core.payment import schemas_payment +from app.core.notification.schemas_notification import Message +from app.core.users import schemas_users +from app.core.utils.config import Settings +from app.module import all_modules +from app.utils.communication.notifications import NotificationTool hyperion_security_logger = logging.getLogger("hyperion.security") hyperion_mypayment_logger = logging.getLogger("hyperion.mypayment") hyperion_error_logger = logging.getLogger("hyperion.error") -LATEST_TOS = 4 -QRCODE_EXPIRATION = 5 # minutes -MYPAYMENT_LOGS_S3_SUBFOLDER = "logs" -RETENTION_DURATION = 10 * 365 # 10 years in days - def verify_signature( public_key_bytes: bytes, signature: str, - data: QRCodeContentData, + data: SecuredContentData, wallet_device_id: UUID, request_id: str, ) -> bool: @@ -46,9 +72,9 @@ def verify_signature( loaded_public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_key_bytes) loaded_public_key.verify( base64.decodebytes(signature.encode("utf-8")), - data.model_dump_json(exclude={"signature", "bypass_membership"}).encode( - "utf-8", - ), + data.model_dump_json( + include=set(SecuredContentData.model_fields.keys()), + ).encode("utf-8"), ) except InvalidSignature: hyperion_security_logger.info( @@ -99,7 +125,7 @@ async def fuse_mypayment_users_utils( async def validate_transfer_callback( - checkout_payment: schemas_payment.CheckoutPayment, + checkout_payment: schemas_checkout.CheckoutPayment, db: AsyncSession, ): paid_amount = checkout_payment.paid_amount @@ -115,6 +141,14 @@ async def validate_transfer_callback( ) raise TransferNotFoundByCallbackError(checkout_id) + wallet = await cruds_mypayment.get_wallet(transfer.wallet_id, db) + + if not wallet: + hyperion_error_logger.error( + f"MyPayment payment callback: wallet with id {transfer.wallet_id} not found for transfer {transfer.id}.", + ) + raise WalletNotFoundOnUpdateError(transfer.wallet_id) + if transfer.total != paid_amount: hyperion_error_logger.error( f"MyPayment payment callback: user transfer {transfer.id} amount does not match the paid amount.", @@ -144,3 +178,455 @@ async def validate_transfer_callback( "s3_retention": RETENTION_DURATION, }, ) + if wallet.store: # This transfer is a direct transfer to a store, it was requested by a module, so we want to call the module callback if it exists + if transfer.module and transfer.object_id: + await call_mypayment_callback( + call_type=RequestType.TRANSFER_REQUEST, + module_root=transfer.module, + object_id=transfer.object_id, + call_id=transfer.id, + db=db, + ) + + +async def request_transaction( + user: schemas_users.CoreUser, + request_info: schemas_mypayment.RequestInfo, + db: AsyncSession, + notification_tool: NotificationTool, +) -> schemas_mypayment.PaymentRequestInfo: + """ + Create a Transaction request for a user from a store. + Create a mypayment payment request between the user wallet and the store wallet + The request need to be accepted or refused by the user using either + - /mypayment/requests/{request_id}/accept + - /mypayment/requests/{request_id}/refuse + """ + payment_user = await cruds_mypayment.get_user_payment(user.id, db) + if not payment_user: + raise PaymentUserNotFoundError(user.id) + start_time = datetime.now(UTC) + expiration_time = start_time + timedelta(minutes=REQUEST_EXPIRATION) + await cruds_mypayment.create_request( + db=db, + request=schemas_mypayment.Request( + id=uuid4(), + wallet_id=payment_user.wallet_id, + creation=start_time, + expiration_date=expiration_time, + total=request_info.total, + store_id=request_info.store_id, + name=request_info.request_name, + store_note=request_info.store_note, + module=request_info.module, + object_id=request_info.object_id, + status=RequestStatus.PROPOSED, + ), + ) + message = Message( + title=f"💸 Nouvelle demande de paiement - {request_info.request_name}", + content=f"Une nouvelle demande de paiement de {request_info.total / 100} € attend votre validation", + action_module=MYPAYMENT_ROOT, + ) + await notification_tool.send_notification_to_user( + user_id=user.id, + message=message, + ) + return schemas_mypayment.PaymentRequestInfo( + end_date=expiration_time, + checkout_url=None, + ) + + +async def request_transfer( + user: schemas_users.CoreUser, + transfer_info: schemas_mypayment.StoreTransferInfo, + db: AsyncSession, + payment_tool: CheckoutTool, + settings: Settings, +) -> schemas_mypayment.PaymentRequestInfo: + """ + Create a Transfer request for a user from a store. + The user should be redirected to the returned `checkout_url` to complete the transfer request. + The transfer will be credited directly to the store wallet. + """ + if transfer_info.redirect_url not in settings.TRUSTED_PAYMENT_REDIRECT_URLS: + hyperion_error_logger.warning( + f"User {user.id} tried to redirect to an untrusted URL: {transfer_info.redirect_url}", + ) + raise HTTPException( + status_code=400, + detail="Redirect URL is not trusted by hyperion", + ) + + if transfer_info.amount < 100: + raise HTTPException( + status_code=400, + detail="Please give an amount in cents, greater than 1€.", + ) + + store = await cruds_mypayment.get_store(transfer_info.store_id, db) + if not store: + raise HTTPException( + status_code=404, + detail=f"Store with id {transfer_info.store_id} not found", + ) + + checkout = await payment_tool.init_checkout( + module=MYPAYMENT_ROOT, + checkout_amount=transfer_info.amount, + checkout_name=f"Recharge {settings.school.payment_name}", + redirection_uri=f"{settings.CLIENT_URL}mypayment/transfer/redirect?url={transfer_info.redirect_url}", + payer_user=user, + db=db, + ) + + await cruds_mypayment.create_transfer( + db=db, + transfer=schemas_mypayment.TransferCreation( + id=uuid4(), + origin=TransferOrigin.HELLO_ASSO, + approver_user_id=user.id, + total=transfer_info.amount, + transfer_identifier=str(checkout.id), + wallet_id=store.wallet_id, + creation=datetime.now(UTC), + confirmed=False, + module=transfer_info.module, + object_id=transfer_info.object_id, + ), + ) + + return schemas_mypayment.PaymentRequestInfo( + end_date=datetime.now(UTC) + timedelta(minutes=CHECKOUT_EXPIRATION), + checkout_url=checkout.payment_url, + ) + + +async def request_payment( + request_type: RequestType, + payment_info: schemas_mypayment.PaymentInfo, + user: schemas_users.CoreUser, + db: AsyncSession, + checkout_tool: CheckoutTool, + notification_tool: NotificationTool, + settings: Settings, +) -> schemas_mypayment.PaymentRequestInfo: + """ + Initiate a payment request. This request can be either: + - a REQUEST_TRANSFER: a checkout will be instantiated, and be credited directly to the store wallet. + In this case, a `checkout_url` url will be returned, the user should be redirected to this url to complete the checkout. + - a REQUEST_TRANSACTION: when accepted by the user, a transaction will be created between the user wallet and the store wallet. + The user should be redirected to mypayment module, to be asked to accept or refuse the transaction request. + + The request is valid until `end_date` + + The `CheckoutTool` must be a *MyPayment* checkout tool + + Use `get_mypayment_tool` dependency to get an instance of `MyPaymentTool`, which will ensure that all dependencies are properly injected. + + When the request is confirmed (checkout validated or transaction accepted), a callback will be called, with the following signature: + ```python + async def mypayment_callback(object_id: UUID, db: AsyncSession) + ``` + """ + # As transfers will be credited to a MyPayment store wallet, we need to ensure that the checkout tool used for transfer requests is a MyPayment checkout tool + if checkout_tool.name != HelloAssoConfigName.MYPAYMENT: + raise InvalidCheckoutToolError(checkout_tool.name) + + if request_type == RequestType.TRANSACTION_REQUEST: + return await request_transaction( + user=user, + request_info=schemas_mypayment.RequestInfo( + total=payment_info.total, + store_id=payment_info.store_id, + request_name=payment_info.request_name, + store_note=payment_info.store_note, + module=payment_info.module, + object_id=payment_info.object_id, + ), + db=db, + notification_tool=notification_tool, + ) + if request_type == RequestType.TRANSFER_REQUEST: + return await request_transfer( + user=user, + transfer_info=schemas_mypayment.StoreTransferInfo( + amount=payment_info.total, + store_id=payment_info.store_id, + module=payment_info.module, + object_id=payment_info.object_id, + redirect_url=payment_info.redirect_url, + ), + db=db, + payment_tool=checkout_tool, + settings=settings, + ) + raise InvalidRequestTypeError(request_type) + + +async def apply_transaction( + user_id: str, + transaction: schemas_mypayment.TransactionBase, + debited_wallet_device: models_mypayment.WalletDevice, + store: models_mypayment.Store, + notification_tool: NotificationTool, + db: AsyncSession, +): + # We increment the receiving wallet balance + await cruds_mypayment.increment_wallet_balance( + wallet_id=transaction.credited_wallet_id, + amount=transaction.total, + db=db, + ) + + # We decrement the debited wallet balance + await cruds_mypayment.increment_wallet_balance( + wallet_id=transaction.debited_wallet_id, + amount=-transaction.total, + db=db, + ) + # We create a transaction + await cruds_mypayment.create_transaction( + transaction=transaction, + debited_wallet_device_id=debited_wallet_device.id, + store_note=None, + db=db, + ) + + hyperion_mypayment_logger.info( + format_transaction_log(transaction), + extra={ + "s3_subfolder": MYPAYMENT_LOGS_S3_SUBFOLDER, + "s3_retention": RETENTION_DURATION, + }, + ) + message = Message( + title=f"💳 Paiement - {store.name}", + content=f"Une transaction de {transaction.total / 100} € a été effectuée", + action_module=MYPAYMENT_ROOT, + ) + await notification_tool.send_notification_to_user( + user_id=user_id, + message=message, + ) + + +async def refund_request( + request: schemas_mypayment.Request, + amount: int, + db: AsyncSession, + notification_tool: NotificationTool, +) -> None: + """ + Refund a payment request. + + Use `get_mypayment_tool` dependency to get an instance of `MyPaymentTool`, which will ensure that all dependencies are properly injected. + """ + if request.status != RequestStatus.ACCEPTED: + raise HTTPException( + status_code=400, + detail="Only accepted payment requests can be refunded", + ) + if amount > request.total: + raise HTTPException( + status_code=400, + detail="Refund amount cannot be greater than the original payment amount", + ) + if request.transaction_id is None: + hyperion_error_logger.error( + f"MyPayment refund: validated request with id {request.id} does not have a transaction_id.", + ) + raise UnlinkedValidatedRequestError(request.id) + transaction = await cruds_mypayment.get_transaction(request.transaction_id, db) + if transaction is None: + hyperion_error_logger.error( + f"MyPayment refund: transaction with id {request.transaction_id} not found for request {request.id}.", + ) + raise UnlinkedValidatedRequestError(request.id) + wallet = await cruds_mypayment.get_wallet(request.wallet_id, db) + if wallet is None: + hyperion_error_logger.error( + f"MyPayment refund: wallet with id {transaction.debited_wallet_id} not found for transaction {transaction.id}.", + ) + raise LinkedWalletNotFoundError(transaction.debited_wallet_id) + if not wallet.user: + hyperion_error_logger.error( + f"MyPayment refund: user not found for wallet with id {wallet.id} for transaction {transaction.id}.", + ) + raise InvalidWalletTypeError(wallet.id, WalletType.USER) + refund = schemas_mypayment.RefundBase( + id=uuid4(), + transaction_id=request.transaction_id, + total=amount, + creation=datetime.now(UTC), + debited_wallet_id=transaction.credited_wallet_id, + credited_wallet_id=transaction.debited_wallet_id, + ) + await cruds_mypayment.create_refund( + db=db, + refund=refund, + ) + await cruds_mypayment.increment_wallet_balance( + wallet_id=transaction.credited_wallet_id, + amount=-amount, + db=db, + ) + await cruds_mypayment.increment_wallet_balance( + wallet_id=transaction.debited_wallet_id, + amount=amount, + db=db, + ) + hyperion_mypayment_logger.info( + format_refund_log(refund), + extra={ + "s3_subfolder": MYPAYMENT_LOGS_S3_SUBFOLDER, + "s3_retention": RETENTION_DURATION, + }, + ) + message = Message( + title=f"💸 Remboursement - {request.name}", + content=f"Un remboursement de {amount / 100} € a été effectué pour la demande de paiement {request.name}", + action_module=MYPAYMENT_ROOT, + ) + await notification_tool.send_notification_to_user( + user_id=wallet.user.id, + message=message, + ) + + +async def refund_direct_transfer( + transfer: schemas_mypayment.Transfer, + amount: int, + db: AsyncSession, + notification_tool: NotificationTool, +) -> None: + """ + Refund a direct transfer. + + Use `get_mypayment_tool` dependency to get an instance of `MyPaymentTool`, which will ensure that all dependencies are properly injected. + """ + if not transfer.confirmed: + raise HTTPException( + status_code=400, + detail="Only confirmed transfers can be refunded", + ) + if not transfer.approver_user_id: + hyperion_error_logger.error( + f"MyPayment refund: transfer with id {transfer.id} does not have an approver_user_id.", + ) + raise InvalidRefundOnTransferError(transfer.id) + if amount > transfer.total: + raise HTTPException( + status_code=400, + detail="Refund amount cannot be greater than the original transfer amount", + ) + user_payment = await cruds_mypayment.get_user_payment(transfer.approver_user_id, db) + if not user_payment: + hyperion_error_logger.error( + f"MyPayment refund: user payment not found for approver_user_id {transfer.approver_user_id} for transfer {transfer.id}.", + ) + raise PaymentUserNotFoundError(transfer.approver_user_id) + user_wallet = await cruds_mypayment.get_wallet(user_payment.wallet_id, db) + if user_wallet is None: + hyperion_error_logger.error( + f"MyPayment refund: wallet with id {user_payment.wallet_id} not found for transfer {transfer.id}.", + ) + raise LinkedWalletNotFoundError(user_payment.wallet_id) + store_wallet = await cruds_mypayment.get_wallet(transfer.wallet_id, db) + if store_wallet is None or not store_wallet.store: + hyperion_error_logger.error( + f"MyPayment refund: store wallet with id {transfer.wallet_id} not found for transfer {transfer.id}.", + ) + raise LinkedWalletNotFoundError(transfer.wallet_id) + await cruds_mypayment.increment_wallet_balance( + wallet_id=transfer.wallet_id, + amount=-amount, + db=db, + ) + await cruds_mypayment.increment_wallet_balance( + wallet_id=user_wallet.id, + amount=amount, + db=db, + ) + transaction = schemas_mypayment.TransactionBase( + id=uuid4(), + debited_wallet_id=transfer.wallet_id, + credited_wallet_id=user_wallet.id, + total=amount, + creation=datetime.now(UTC), + status=TransactionStatus.CONFIRMED, + transaction_type=TransactionType.DIRECT, + seller_user_id=None, + ) + await cruds_mypayment.create_transaction( + transaction=transaction, + debited_wallet_device_id=None, + store_note=f"Refund for direct transfer {transfer.id}", + db=db, + ) + hyperion_mypayment_logger.info( + format_transaction_log(transaction), + extra={ + "s3_subfolder": MYPAYMENT_LOGS_S3_SUBFOLDER, + "s3_retention": RETENTION_DURATION, + }, + ) + message = Message( + title="💸 Remboursement - Transfert direct", + content=f"Un remboursement de {amount / 100} € a été effectué par {store_wallet.store.name}", + action_module=MYPAYMENT_ROOT, + ) + await notification_tool.send_notification_to_user( + user_id=transfer.approver_user_id, + message=message, + ) + + +async def call_mypayment_callback( + call_type: RequestType, + module_root: str, + object_id: UUID, + call_id: UUID, + db: AsyncSession, +): + id_name = ( + "transfer_id" if call_type == RequestType.TRANSFER_REQUEST else "request_id" + ) + try: + for module in all_modules: + if module.root == module_root: + if module.mypayment_callback is None: + hyperion_error_logger.info( + f"MyPayment: module {module_root} does not define a request callback ({id_name}: {call_id})", + ) + return + hyperion_error_logger.info( + f"MyPayment: calling module {module_root} request callback", + ) + await module.mypayment_callback(object_id, db) + hyperion_error_logger.info( + f"MyPayment: call to module {module_root} request callback ({id_name}: {call_id}) succeeded", + ) + return + + hyperion_error_logger.info( + f"MyPayment: request callback for module {module_root} not found ({id_name}: {call_id})", + ) + except Exception: + hyperion_error_logger.exception( + f"MyPayment: call to module {module_root} request callback ({id_name}: {call_id}) failed", + ) + + +async def can_user_manage_events( + user_id: str, + store_id: UUID, + db: AsyncSession, +): + seller = await cruds_mypayment.get_seller( + user_id=user_id, + store_id=store_id, + db=db, + ) + return seller is not None and seller.can_manage_events diff --git a/app/core/users/cruds_users.py b/app/core/users/cruds_users.py index f1655c9d8f..679e97ccbc 100644 --- a/app/core/users/cruds_users.py +++ b/app/core/users/cruds_users.py @@ -11,7 +11,6 @@ from app.core.groups import models_groups from app.core.groups.groups_type import AccountType -from app.core.mypayment.utils_mypayment import fuse_mypayment_users_utils from app.core.schools.schools_type import SchoolType from app.core.users import models_users, schemas_users @@ -393,6 +392,9 @@ async def fusion_users( a. If it doesn't, we update the row b. If it does, we delete the row """ + + from app.core.mypayment.utils_mypayment import fuse_mypayment_users_utils + await fuse_mypayment_users_utils(db, user_kept_id, user_deleted_id) foreign_keys: set[ForeignKey] = get_referencing_foreign_keys( diff --git a/app/core/utils/config.py b/app/core/utils/config.py index 344a2d09ef..48c58ec8d4 100644 --- a/app/core/utils/config.py +++ b/app/core/utils/config.py @@ -16,8 +16,8 @@ YamlConfigSettingsSource, ) +from app.core.checkout.types_checkout import HelloAssoConfig, HelloAssoConfigName from app.core.core_endpoints.schemas_core import MainActivationForm -from app.core.payment.types_payment import HelloAssoConfig, HelloAssoConfigName from app.types.exceptions import ( DotenvInvalidAuthClientNameInError, DotenvInvalidVariableError, diff --git a/app/dependencies.py b/app/dependencies.py index 55fa32a42f..e73a847962 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -22,9 +22,10 @@ async def get_users(db: AsyncSession = Depends(get_db)): ) from app.core.auth import schemas_auth +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.checkout.types_checkout import HelloAssoConfigName from app.core.groups.groups_type import AccountType, GroupType, get_ecl_account_types -from app.core.payment.payment_tool import PaymentTool -from app.core.payment.types_payment import HelloAssoConfigName +from app.core.mypayment.mypayment_tool import MyPaymentTool from app.core.permissions import cruds_permissions from app.core.permissions.type_permissions import ModulePermissions from app.core.users import cruds_users, models_users @@ -45,9 +46,9 @@ async def get_users(db: AsyncSession = Depends(get_db)): disconnect_redis_client, disconnect_scheduler, disconnect_websocket_connection_manager, + init_checkout_tools, init_engine, init_mail_templates, - init_payment_tools, init_redis_client, init_scheduler, init_SessionLocal, @@ -107,7 +108,7 @@ async def init_state( notification_manager = NotificationManager(settings=settings) - payment_tools = init_payment_tools( + checkout_tools = init_checkout_tools( settings=settings, hyperion_error_logger=hyperion_error_logger, ) @@ -121,7 +122,7 @@ async def init_state( scheduler=scheduler, ws_manager=ws_manager, notification_manager=notification_manager, - payment_tools=payment_tools, + checkout_tools=checkout_tools, mail_templates=mail_templates, ) @@ -273,20 +274,40 @@ def get_notification_tool( @lru_cache -def get_payment_tool( +def get_checkout_tool( name: HelloAssoConfigName, -) -> Callable[[], PaymentTool]: - def get_payment_tool() -> PaymentTool: - payment_tools = GLOBAL_STATE["payment_tools"] - if name not in payment_tools: +) -> Callable[[], CheckoutTool]: + def get_checkout_tool() -> CheckoutTool: + checkout_tools = GLOBAL_STATE["checkout_tools"] + if name not in checkout_tools: hyperion_error_logger.warning( f"HelloAsso API credentials are not set for {name.value}, payment won't be available", ) raise PaymentToolCredentialsNotSetException - return payment_tools[name] + return checkout_tools[name] - return get_payment_tool + return get_checkout_tool + + +def get_mypayment_tool( + db: AsyncSession = Depends(get_db), + checkout_tool: CheckoutTool = Depends( + get_checkout_tool(name=HelloAssoConfigName.MYPAYMENT), + ), + notification_tool: NotificationTool = Depends(get_notification_tool), + settings: Settings = Depends(get_settings), +) -> MyPaymentTool: + """ + Dependency that returns a MyPaymentTool, allowing to interact with the MyPayment core module. + """ + + return MyPaymentTool( + db=db, + checkout_tool=checkout_tool, + notification_tool=notification_tool, + settings=settings, + ) def get_mail_templates() -> calypsso.MailTemplates: diff --git a/app/modules/cdr/endpoints_cdr.py b/app/modules/cdr/endpoints_cdr.py index d4af676047..8807b65cd2 100644 --- a/app/modules/cdr/endpoints_cdr.py +++ b/app/modules/cdr/endpoints_cdr.py @@ -15,19 +15,19 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.checkout.types_checkout import HelloAssoConfigName from app.core.groups import cruds_groups, schemas_groups from app.core.groups.groups_type import AccountType from app.core.memberships import cruds_memberships, schemas_memberships -from app.core.payment.payment_tool import PaymentTool -from app.core.payment.types_payment import HelloAssoConfigName from app.core.permissions.type_permissions import ModulePermissions from app.core.users import cruds_users, models_users, schemas_users from app.core.users.cruds_users import get_user_by_id, get_users from app.core.utils.config import Settings from app.dependencies import ( + get_checkout_tool, get_db, get_mail_templates, - get_payment_tool, get_settings, get_unsafe_db, get_websocket_connection_manager, @@ -71,7 +71,7 @@ class CdrPermissions(ModulePermissions): module = Module( root="cdr", tag="Cdr", - payment_callback=validate_payment, + checkout_callback=validate_payment, default_allowed_account_types=list(AccountType), factory=None, permissions=CdrPermissions, @@ -2761,7 +2761,7 @@ async def get_payment_url( is_user_allowed_to([CdrPermissions.access_cdr]), ), settings: Settings = Depends(get_settings), - payment_tool: PaymentTool = Depends(get_payment_tool(HelloAssoConfigName.CDR)), + payment_tool: CheckoutTool = Depends(get_checkout_tool(HelloAssoConfigName.CDR)), cdr_year: coredata_cdr.CdrYear = Depends(get_current_cdr_year), ): """ diff --git a/app/modules/cdr/utils_cdr.py b/app/modules/cdr/utils_cdr.py index 1fb940ba97..b949536a9d 100644 --- a/app/modules/cdr/utils_cdr.py +++ b/app/modules/cdr/utils_cdr.py @@ -9,7 +9,7 @@ ) from sqlalchemy.ext.asyncio import AsyncSession -from app.core.payment import schemas_payment +from app.core.checkout import schemas_checkout from app.core.permissions.type_permissions import ModulePermissions from app.core.users import models_users from app.dependencies import ( @@ -34,7 +34,7 @@ class CdrPermissions(ModulePermissions): async def validate_payment( - checkout_payment: schemas_payment.CheckoutPayment, + checkout_payment: schemas_checkout.CheckoutPayment, db: AsyncSession, ) -> None: paid_amount = checkout_payment.paid_amount diff --git a/app/modules/mypayment/__init__.py b/app/modules/mypayment/__init__.py deleted file mode 100644 index 8b13789179..0000000000 --- a/app/modules/mypayment/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/modules/mypayment/endpoints_mypayment.py b/app/modules/mypayment/endpoints_mypayment.py deleted file mode 100644 index a101a85116..0000000000 --- a/app/modules/mypayment/endpoints_mypayment.py +++ /dev/null @@ -1,16 +0,0 @@ -from app.core.groups.groups_type import AccountType -from app.core.permissions.type_permissions import ModulePermissions -from app.types.module import Module - - -class MyPaymentPermissions(ModulePermissions): - access_payment = "access_payment" - - -module = Module( - root="mypayment", - tag="MyPayment", - default_allowed_account_types=[AccountType.student, AccountType.staff], - factory=None, - permissions=MyPaymentPermissions, -) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index b23c60f345..d214780a48 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -7,14 +7,14 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.checkout.types_checkout import HelloAssoConfigName from app.core.groups.groups_type import AccountType -from app.core.payment.payment_tool import PaymentTool -from app.core.payment.types_payment import HelloAssoConfigName from app.core.permissions.type_permissions import ModulePermissions from app.core.users import models_users, schemas_users from app.dependencies import ( + get_checkout_tool, get_db, - get_payment_tool, is_user_allowed_to, ) from app.modules.raid import coredata_raid, cruds_raid, models_raid, schemas_raid @@ -50,7 +50,7 @@ class RaidPermissions(ModulePermissions): module = Module( root="raid", tag="Raid", - payment_callback=validate_payment, + checkout_callback=validate_payment, default_allowed_account_types=list(AccountType), factory=None, permissions=RaidPermissions, @@ -949,7 +949,7 @@ async def get_payment_url( user: models_users.CoreUser = Depends( is_user_allowed_to([RaidPermissions.access_raid]), ), - payment_tool: PaymentTool = Depends(get_payment_tool(HelloAssoConfigName.RAID)), + payment_tool: CheckoutTool = Depends(get_checkout_tool(HelloAssoConfigName.RAID)), ): """ Get payment url diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py index 6bbdd3d1ea..e3ea06357e 100644 --- a/app/modules/raid/utils/utils_raid.py +++ b/app/modules/raid/utils/utils_raid.py @@ -9,7 +9,7 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.payment import schemas_payment +from app.core.checkout import schemas_checkout from app.modules.raid import coredata_raid, cruds_raid, models_raid, schemas_raid from app.modules.raid.raid_type import Difficulty, Size from app.modules.raid.schemas_raid import ( @@ -73,7 +73,7 @@ def will_participant_be_minor_on( async def validate_payment( - checkout_payment: schemas_payment.CheckoutPayment, + checkout_payment: schemas_checkout.CheckoutPayment, db: AsyncSession, ) -> None: paid_amount = checkout_payment.paid_amount diff --git a/app/modules/sport_competition/endpoints_sport_competition.py b/app/modules/sport_competition/endpoints_sport_competition.py index 70e7db5ea9..84b655477f 100644 --- a/app/modules/sport_competition/endpoints_sport_competition.py +++ b/app/modules/sport_competition/endpoints_sport_competition.py @@ -7,15 +7,15 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.checkout.types_checkout import HelloAssoConfigName from app.core.groups.groups_type import AccountType, get_account_types_except_externals -from app.core.payment.payment_tool import PaymentTool -from app.core.payment.types_payment import HelloAssoConfigName from app.core.schools import cruds_schools from app.core.schools.schools_type import SchoolType from app.core.users import cruds_users, models_users, schemas_users from app.dependencies import ( + get_checkout_tool, get_db, - get_payment_tool, is_user_allowed_to, ) from app.modules.sport_competition import ( @@ -81,7 +81,7 @@ root="sport_competition", tag="Sport Competition", default_allowed_account_types=get_account_types_except_externals(), - payment_callback=validate_payment, + checkout_callback=validate_payment, factory=None, permissions=SportCompetitionPermissions, ) @@ -4443,8 +4443,8 @@ async def get_payment_url( user: models_users.CoreUser = Depends( is_user_allowed_to([SportCompetitionPermissions.access_sport_competition]), ), - payment_tool: PaymentTool = Depends( - get_payment_tool(HelloAssoConfigName.CHALLENGER), + payment_tool: CheckoutTool = Depends( + get_checkout_tool(HelloAssoConfigName.CHALLENGER), ), edition: schemas_sport_competition.CompetitionEdition = Depends( get_current_edition, diff --git a/app/modules/sport_competition/utils_sport_competition.py b/app/modules/sport_competition/utils_sport_competition.py index 9312fcffd1..4932bab04c 100644 --- a/app/modules/sport_competition/utils_sport_competition.py +++ b/app/modules/sport_competition/utils_sport_competition.py @@ -4,7 +4,7 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.payment import schemas_payment +from app.core.checkout import schemas_checkout from app.core.schools.schools_type import SchoolType from app.modules.sport_competition import ( cruds_sport_competition, @@ -123,7 +123,7 @@ def validate_product_variant_purchase( async def validate_payment( - checkout_payment: schemas_payment.CheckoutPayment, + checkout_payment: schemas_checkout.CheckoutPayment, db: AsyncSession, ) -> None: paid_amount = checkout_payment.paid_amount diff --git a/app/types/exceptions.py b/app/types/exceptions.py index d7f2ac9d60..d3b3266302 100644 --- a/app/types/exceptions.py +++ b/app/types/exceptions.py @@ -3,7 +3,7 @@ from fastapi import HTTPException -from app.core.payment.types_payment import HelloAssoConfigName +from app.core.checkout.types_checkout import HelloAssoConfigName class InvalidAppStateTypeError(Exception): @@ -83,7 +83,7 @@ def __init__(self): class UnsetRedirectionUriError(Exception): def __init__(self): - super().__init__("No redirection URI set in the PaymentTool configuration.") + super().__init__("No redirection URI set in the CheckoutTool configuration.") class FileNameIsNotAnUUIDError(Exception): diff --git a/app/types/module.py b/app/types/module.py index 39ebdb6e61..68152a6a8a 100644 --- a/app/types/module.py +++ b/app/types/module.py @@ -1,11 +1,12 @@ from collections.abc import Awaitable, Callable +from uuid import UUID from fastapi import APIRouter from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout import schemas_checkout from app.core.groups.groups_type import AccountType, GroupType from app.core.notification.schemas_notification import Topic -from app.core.payment import schemas_payment from app.core.permissions.type_permissions import ModulePermissions from app.types.factory import Factory @@ -17,8 +18,13 @@ def __init__( tag: str, factory: Factory | None, router: APIRouter | None = None, - payment_callback: Callable[ - [schemas_payment.CheckoutPayment, AsyncSession], + checkout_callback: Callable[ + [schemas_checkout.CheckoutPayment, AsyncSession], + Awaitable[None], + ] + | None = None, + mypayment_callback: Callable[ + [UUID, AsyncSession], Awaitable[None], ] | None = None, @@ -38,10 +44,13 @@ def __init__( """ self.root = root self.router = router or APIRouter(tags=[tag]) - self.payment_callback: ( - Callable[[schemas_payment.CheckoutPayment, AsyncSession], Awaitable[None]] + self.checkout_callback: ( + Callable[[schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None]] | None - ) = payment_callback + ) = checkout_callback + self.mypayment_callback: ( + Callable[[UUID, AsyncSession], Awaitable[None]] | None + ) = mypayment_callback self.registred_topics = registred_topics self.factory = factory self.permissions = permissions @@ -56,8 +65,13 @@ def __init__( default_allowed_groups_ids: list[GroupType] | None = None, default_allowed_account_types: list[AccountType] | None = None, router: APIRouter | None = None, - payment_callback: Callable[ - [schemas_payment.CheckoutPayment, AsyncSession], + checkout_callback: Callable[ + [schemas_checkout.CheckoutPayment, AsyncSession], + Awaitable[None], + ] + | None = None, + mypayment_callback: Callable[ + [UUID, AsyncSession], Awaitable[None], ] | None = None, @@ -81,10 +95,13 @@ def __init__( self.default_allowed_groups_ids = default_allowed_groups_ids self.default_allowed_account_types = default_allowed_account_types self.router = router or APIRouter(tags=[tag]) - self.payment_callback: ( - Callable[[schemas_payment.CheckoutPayment, AsyncSession], Awaitable[None]] + self.checkout_callback: ( + Callable[[schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None]] | None - ) = payment_callback + ) = checkout_callback + self.mypayment_callback: ( + Callable[[UUID, AsyncSession], Awaitable[None]] | None + ) = mypayment_callback self.registred_topics = registred_topics self.factory = factory self.permissions = permissions diff --git a/app/utils/state.py b/app/utils/state.py index cbb8f4cc1a..52804de119 100644 --- a/app/utils/state.py +++ b/app/utils/state.py @@ -11,8 +11,8 @@ create_async_engine, ) -from app.core.payment.payment_tool import PaymentTool -from app.core.payment.types_payment import HelloAssoConfigName +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.checkout.types_checkout import HelloAssoConfigName from app.core.utils.config import Settings from app.types.scheduler import OfflineScheduler, Scheduler from app.types.sqlalchemy import SessionLocalType @@ -34,7 +34,7 @@ class GlobalState(TypedDict): scheduler: Scheduler ws_manager: WebsocketConnectionManager notification_manager: NotificationManager - payment_tools: dict[HelloAssoConfigName, PaymentTool] + checkout_tools: dict[HelloAssoConfigName, CheckoutTool] mail_templates: calypsso.MailTemplates @@ -147,19 +147,20 @@ async def disconnect_websocket_connection_manager( await ws_manager.disconnect_broadcaster() -def init_payment_tools( +def init_checkout_tools( settings: Settings, hyperion_error_logger: logging.Logger, -) -> dict[HelloAssoConfigName, PaymentTool]: +) -> dict[HelloAssoConfigName, CheckoutTool]: if settings.HELLOASSO_API_BASE is None: hyperion_error_logger.warning( "HelloAsso API base URL is not set in settings, payment won't be available", ) return {} - payment_tools: dict[HelloAssoConfigName, PaymentTool] = {} + payment_tools: dict[HelloAssoConfigName, CheckoutTool] = {} for helloasso_config_name in settings.HELLOASSO_CONFIGURATIONS: - payment_tools[helloasso_config_name] = PaymentTool( + payment_tools[helloasso_config_name] = CheckoutTool( + name=helloasso_config_name, config=settings.HELLOASSO_CONFIGURATIONS[helloasso_config_name], helloasso_api_base=settings.HELLOASSO_API_BASE, ) diff --git a/migrations/versions/60-mypayment-extended.py b/migrations/versions/60-mypayment-extended.py new file mode 100644 index 0000000000..d454a67dc9 --- /dev/null +++ b/migrations/versions/60-mypayment-extended.py @@ -0,0 +1,117 @@ +"""empty message + +Create Date: 2026-03-19 15:49:33.554684 +""" + +from collections.abc import Sequence +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "46fbbcee7237" +down_revision: str | None = "7dbe3290e145" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +class TransferOrigin(Enum): + HELLO_ASSO = "HELLO_ASSO" + CHECK = "CHECK" + CASH = "CASH" + BANK_TRANSFER = "BANK_TRANSFER" + + +class TransferType(Enum): + HELLO_ASSO = "HELLO_ASSO" + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("mypayment_request", sa.Column("module", sa.String(), nullable=False)) + op.add_column( + "mypayment_request", + sa.Column("object_id", sa.Uuid(), nullable=False), + ) + op.drop_column("mypayment_request", "callback") + op.add_column("mypayment_transfer", sa.Column("module", sa.String(), nullable=True)) + op.add_column( + "mypayment_transfer", + sa.Column("object_id", sa.Uuid(), nullable=True), + ) + op.add_column( + "mypayment_seller", + sa.Column("can_manage_events", sa.Boolean(), nullable=False), + ) + op.alter_column( + "mypayment_transaction", + "debited_wallet_device_id", + existing_type=sa.UUID(), + nullable=True, + ) + postgresql.ENUM(TransferOrigin, name="transferorigin").create(op.get_bind()) + op.alter_column( + "mypayment_transfer", + "type", + new_column_name="origin", + type_=sa.Enum(TransferOrigin, name="transferorigin"), + existing_type=sa.Enum(TransferType, name="transfertype"), + postgresql_using="type::text::transferorigin", + ) + sa.Enum(TransferType, name="transfertype").drop(op.get_bind()) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("mypayment_transfer", "object_id") + op.drop_column("mypayment_transfer", "module") + op.add_column( + "mypayment_request", + sa.Column("callback", sa.VARCHAR(), autoincrement=False, nullable=False), + ) + op.drop_column("mypayment_request", "module") + op.drop_column("mypayment_request", "object_id") + op.drop_column("mypayment_seller", "can_manage_events") + op.alter_column( + "mypayment_transaction", + "debited_wallet_device_id", + existing_type=sa.UUID(), + nullable=False, + ) + postgresql.ENUM( + TransferType, + name="transfertype", + ).create(op.get_bind()) + op.alter_column( + "mypayment_transfer", + "origin", + existing_type=sa.Enum(TransferOrigin, name="transferorigin"), + type_=sa.Enum(TransferType, name="transfertype"), + new_column_name="type", + postgresql_using="origin::text::transfertype", + ) + sa.Enum(TransferOrigin, name="transferorigin").drop(op.get_bind()) + + # ### end Alembic commands ### + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/tests/commons.py b/tests/commons.py index 6d187f4772..d87d7bb081 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -14,11 +14,11 @@ from app import dependencies from app.core.auth import schemas_auth +from app.core.checkout import cruds_checkout, models_checkout, schemas_checkout +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.checkout.types_checkout import HelloAssoConfig, HelloAssoConfigName from app.core.groups import cruds_groups, models_groups from app.core.groups.groups_type import AccountType, GroupType -from app.core.payment import cruds_payment, models_payment, schemas_payment -from app.core.payment.payment_tool import PaymentTool -from app.core.payment.types_payment import HelloAssoConfig, HelloAssoConfigName from app.core.permissions import cruds_permissions, schemas_permissions from app.core.schools.schools_type import SchoolType from app.core.users import cruds_users, models_users, schemas_users @@ -73,7 +73,7 @@ async def override_init_state( notification_manager = NotificationManager(settings=settings) - payment_tools = init_test_payment_tools() + checkout_tools = init_test_checkout_tools() mail_templates = init_mail_templates(settings=settings) @@ -84,7 +84,7 @@ async def override_init_state( scheduler=scheduler, ws_manager=ws_manager, notification_manager=notification_manager, - payment_tools=payment_tools, + checkout_tools=checkout_tools, mail_templates=mail_templates, ) @@ -151,10 +151,10 @@ def init_test_SessionLocal(engine: AsyncEngine) -> SessionLocalType: return TestingSessionLocal -def init_test_payment_tools() -> dict[HelloAssoConfigName, PaymentTool]: - payment_tools: dict[HelloAssoConfigName, PaymentTool] = {} +def init_test_checkout_tools() -> dict[HelloAssoConfigName, CheckoutTool]: + payment_tools: dict[HelloAssoConfigName, CheckoutTool] = {} for helloasso_config_name in HelloAssoConfigName: - payment_tools[helloasso_config_name] = MockedPaymentTool() + payment_tools[helloasso_config_name] = MockedCheckoutTool() return payment_tools @@ -347,11 +347,12 @@ async def add_coredata_to_db( mocked_checkout_id: uuid.UUID = uuid.UUID("81c9ad91-f415-494a-96ad-87bf647df82c") -class MockedPaymentTool(PaymentTool): +class MockedCheckoutTool(CheckoutTool): def __init__( self, ): - self.payment_tool = PaymentTool( + self.payment_tool = CheckoutTool( + name=HelloAssoConfigName.CDR, config=HelloAssoConfig( helloasso_client_id="client", helloasso_client_secret="secret", @@ -372,10 +373,10 @@ async def init_checkout( db: AsyncSession, payer_user: schemas_users.CoreUser | None = None, redirection_uri: str | None = None, - ) -> schemas_payment.Checkout: - exist = await cruds_payment.get_checkout_by_id(mocked_checkout_id, db) + ) -> schemas_checkout.Checkout: + exist = await cruds_checkout.get_checkout_by_id(mocked_checkout_id, db) if exist is None: - checkout_model = models_payment.Checkout( + checkout_model = models_checkout.Checkout( id=mocked_checkout_id, module=module, name=checkout_name, @@ -383,9 +384,9 @@ async def init_checkout( hello_asso_checkout_id=123, secret="checkoutsecret", ) - await cruds_payment.create_checkout(db, checkout_model) + await cruds_checkout.create_checkout(db, checkout_model) - return schemas_payment.Checkout( + return schemas_checkout.Checkout( id=mocked_checkout_id, payment_url="https://some.url.fr/checkout", ) @@ -394,7 +395,7 @@ async def get_checkout( self, checkout_id: uuid.UUID, db: AsyncSession, - ) -> schemas_payment.CheckoutComplete | None: + ) -> schemas_checkout.CheckoutComplete | None: return await self.payment_tool.get_checkout( checkout_id=checkout_id, db=db, diff --git a/tests/config.test.yaml b/tests/config.test.yaml index ffcf65f46e..93005e9967 100644 --- a/tests/config.test.yaml +++ b/tests/config.test.yaml @@ -199,4 +199,4 @@ TRUSTED_PAYMENT_REDIRECT_URLS: # MyECLPay requires an external service to recurrently check for transactions and state integrity, this service needs an access to all the data related to the transactions and the users involved # This service will use a special token to access the data # If this token is not set, the service will not be able to access the data and no integrity check will be performed -#MYECLPAY_DATA_VERIFIER_ACCESS_TOKEN: "" +MYPAYMENT_DATA_VERIFIER_ACCESS_TOKEN: test_data_verifier_access_token diff --git a/tests/core/test_payment.py b/tests/core/test_checkout.py similarity index 88% rename from tests/core/test_payment.py rename to tests/core/test_checkout.py index 50058836b1..2fda252d44 100644 --- a/tests/core/test_payment.py +++ b/tests/core/test_checkout.py @@ -14,14 +14,14 @@ from pytest_mock import MockerFixture from sqlalchemy.ext.asyncio import AsyncSession -from app.core.payment import cruds_payment, models_payment, schemas_payment -from app.core.payment.payment_tool import PaymentTool -from app.core.payment.types_payment import HelloAssoConfig, HelloAssoConfigName +from app.core.checkout import cruds_checkout, models_checkout, schemas_checkout +from app.core.checkout.checkout_tool import CheckoutTool +from app.core.checkout.types_checkout import HelloAssoConfig, HelloAssoConfigName from app.core.schools import schemas_schools from app.core.users import schemas_users from app.types.module import Module from tests.commons import ( - MockedPaymentTool, + MockedCheckoutTool, add_object_to_db, create_user_with_groups, get_TestingSessionLocal, @@ -30,9 +30,9 @@ if TYPE_CHECKING: from app.core.utils.config import Settings -checkout_with_existing_checkout_payment: models_payment.Checkout -existing_checkout_payment: models_payment.CheckoutPayment -checkout: models_payment.Checkout +checkout_with_existing_checkout_payment: models_checkout.Checkout +existing_checkout_payment: models_checkout.CheckoutPayment +checkout: models_checkout.Checkout user_schema: schemas_users.CoreUser @@ -43,7 +43,7 @@ async def init_objects() -> None: global checkout_with_existing_checkout_payment checkout_with_existing_checkout_payment_id = uuid.uuid4() - checkout_with_existing_checkout_payment = models_payment.Checkout( + checkout_with_existing_checkout_payment = models_checkout.Checkout( id=checkout_with_existing_checkout_payment_id, module=TEST_MODULE_ROOT, name="Test Payment", @@ -54,7 +54,7 @@ async def init_objects() -> None: await add_object_to_db(checkout_with_existing_checkout_payment) global existing_checkout_payment - existing_checkout_payment = models_payment.CheckoutPayment( + existing_checkout_payment = models_checkout.CheckoutPayment( id=uuid.uuid4(), checkout_id=checkout_with_existing_checkout_payment_id, paid_amount=100, @@ -64,7 +64,7 @@ async def init_objects() -> None: await add_object_to_db(existing_checkout_payment) global checkout - checkout = models_payment.Checkout( + checkout = models_checkout.Checkout( id=uuid.uuid4(), module="tests", name="Test Payment", @@ -94,7 +94,7 @@ async def init_objects() -> None: def test_webhook_with_invalid_body(client: TestClient) -> None: response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "invalid": "body", }, @@ -105,7 +105,7 @@ def test_webhook_with_invalid_body(client: TestClient) -> None: def test_webhook_order(client: TestClient) -> None: response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Order", "data": {}, @@ -122,11 +122,11 @@ def test_webhook_payment_for_already_received_payment( This situation could happen if HelloAsso call our webhook multiple times for the same payment. """ mocked_hyperion_security_logger = mocker.patch( - "app.core.payment.endpoints_payment.hyperion_error_logger.debug", + "app.core.checkout.endpoints_checkout.hyperion_error_logger.debug", ) response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": { @@ -154,7 +154,7 @@ def test_webhook_payment_without_metadata( """ response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": { @@ -175,7 +175,7 @@ def test_webhook_payment_with_non_existing_checkout( """ response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": { @@ -204,7 +204,7 @@ def test_webhook_payment_with_invalid_helloasso_secret( """ response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": { @@ -228,7 +228,7 @@ async def test_webhook_payment( # We will simulate a first payment of 0,7 € then a payment of 0,3 € response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": { @@ -245,7 +245,7 @@ async def test_webhook_payment( assert response.status_code == 204 async with get_TestingSessionLocal()() as db: - checkout_model = await cruds_payment.get_checkout_by_id( + checkout_model = await cruds_checkout.get_checkout_by_id( checkout_id=checkout.id, db=db, ) @@ -254,7 +254,7 @@ async def test_webhook_payment( assert checkout_model.payments[0].paid_amount == 70 response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": { @@ -271,7 +271,7 @@ async def test_webhook_payment( assert response.status_code == 204 async with get_TestingSessionLocal()() as db: - checkout_model = await cruds_payment.get_checkout_by_id( + checkout_model = await cruds_checkout.get_checkout_by_id( checkout_id=checkout.id, db=db, ) @@ -284,7 +284,7 @@ async def test_webhook_payment( async def callback( - checkout_payment: schemas_payment.CheckoutPayment, + checkout_payment: schemas_checkout.CheckoutPayment, db: AsyncSession, ) -> None: pass @@ -296,7 +296,7 @@ async def test_webhook_payment_callback( ) -> None: # We patch the callback to be able to check if it was called mocked_callback = mocker.patch( - "tests.core.test_payment.callback", + "tests.core.test_checkout.callback", ) # We patch the module_list to inject our custom test module @@ -304,17 +304,17 @@ async def test_webhook_payment_callback( root=TEST_MODULE_ROOT, tag="Tests", default_allowed_groups_ids=[], - payment_callback=callback, + checkout_callback=callback, factory=None, permissions=None, ) mocker.patch( - "app.core.payment.endpoints_payment.all_modules", + "app.core.checkout.endpoints_checkout.all_modules", [test_module], ) response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": { @@ -338,7 +338,7 @@ async def test_webhook_payment_callback_fail( ) -> None: # We patch the callback to be able to check if it was called mocked_callback = mocker.patch( - "tests.core.test_payment.callback", + "tests.core.test_checkout.callback", side_effect=ValueError("Test error"), ) @@ -347,21 +347,21 @@ async def test_webhook_payment_callback_fail( root=TEST_MODULE_ROOT, tag="Tests", default_allowed_groups_ids=[], - payment_callback=callback, + checkout_callback=callback, factory=None, permissions=None, ) mocker.patch( - "app.core.payment.endpoints_payment.all_modules", + "app.core.checkout.endpoints_checkout.all_modules", [test_module], ) mocked_hyperion_security_logger = mocker.patch( - "app.core.payment.endpoints_payment.hyperion_error_logger.exception", + "app.core.checkout.endpoints_checkout.hyperion_error_logger.exception", ) response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": { @@ -388,7 +388,7 @@ async def test_webhook_payment_callback_fail( async def test_payment_tool_get_checkout( client: TestClient, ): - payment_tool = MockedPaymentTool() + payment_tool = MockedCheckoutTool() async with get_TestingSessionLocal()() as db: # Get existing checkout @@ -422,7 +422,8 @@ async def test_payment_tool_init_checkout( redirection_uri=redirect_url, ), } - payment_tool = PaymentTool( + payment_tool = CheckoutTool( + name=HelloAssoConfigName.CDR, config=settings.HELLOASSO_CONFIGURATIONS[HelloAssoConfigName.CDR], helloasso_api_base=settings.HELLOASSO_API_BASE, ) @@ -442,7 +443,7 @@ async def test_payment_tool_init_checkout( redirect_url=redirect_url, ) mocker.patch( - "app.core.payment.payment_tool.CheckoutApi", + "app.core.checkout.checkout_tool.CheckoutApi", return_value=mock_checkout_api, ) @@ -485,7 +486,8 @@ async def test_payment_tool_init_checkout_with_one_failure( ), } - payment_tool = PaymentTool( + payment_tool = CheckoutTool( + name=HelloAssoConfigName.CDR, config=settings.HELLOASSO_CONFIGURATIONS[HelloAssoConfigName.CDR], helloasso_api_base=settings.HELLOASSO_API_BASE, ) @@ -515,7 +517,7 @@ def init_a_checkout_side_effect( mock_checkout_api = mocker.MagicMock() mock_checkout_api.organizations_organization_slug_checkout_intents_post.side_effect = init_a_checkout_side_effect mocker.patch( - "app.core.payment.payment_tool.CheckoutApi", + "app.core.checkout.checkout_tool.CheckoutApi", return_value=mock_checkout_api, ) @@ -544,7 +546,7 @@ async def test_payment_tool_init_checkout_fail( client: TestClient, ) -> None: mocked_hyperion_security_logger = mocker.patch( - "app.core.payment.endpoints_payment.hyperion_error_logger.error", + "app.core.checkout.endpoints_checkout.hyperion_error_logger.error", ) redirect_url = "https://example.com" @@ -560,7 +562,8 @@ async def test_payment_tool_init_checkout_fail( ), } - payment_tool = PaymentTool( + payment_tool = CheckoutTool( + name=HelloAssoConfigName.CDR, config=settings.HELLOASSO_CONFIGURATIONS[HelloAssoConfigName.CDR], helloasso_api_base=settings.HELLOASSO_API_BASE, ) @@ -580,7 +583,7 @@ async def test_payment_tool_init_checkout_fail( ) mocker.patch( - "app.core.payment.payment_tool.CheckoutApi", + "app.core.checkout.checkout_tool.CheckoutApi", return_value=mock_checkout_api, ) diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 09e5ca0ca9..00a60b0ea8 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -10,24 +10,37 @@ ) from fastapi.testclient import TestClient from pytest_mock import MockerFixture +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout import schemas_checkout from app.core.groups import models_groups -from app.core.groups.groups_type import GroupType +from app.core.groups.groups_type import AccountType, GroupType from app.core.memberships import models_memberships from app.core.mypayment import cruds_mypayment, models_mypayment from app.core.mypayment.coredata_mypayment import ( MyPaymentBankAccountHolder, ) -from app.core.mypayment.schemas_mypayment import QRCodeContentData +from app.core.mypayment.endpoints_mypayment import MyPaymentPermissions +from app.core.mypayment.schemas_mypayment import ( + SecuredContentData, + SignedContent, +) from app.core.mypayment.types_mypayment import ( + LATEST_TOS, + REQUEST_EXPIRATION, + RequestStatus, TransactionStatus, TransactionType, - TransferType, + TransferOrigin, WalletDeviceStatus, WalletType, ) -from app.core.mypayment.utils_mypayment import LATEST_TOS +from app.core.mypayment.utils_mypayment import ( + validate_transfer_callback, +) +from app.core.permissions import models_permissions from app.core.users import models_users +from app.types.module import Module from tests.commons import ( add_coredata_to_db, add_object_to_db, @@ -37,6 +50,8 @@ get_TestingSessionLocal, ) +TEST_MODULE_ROOT = "tests" + bde_group: models_groups.CoreGroup admin_user: models_users.CoreUser @@ -45,10 +60,6 @@ structure_manager_user_token: str structure2_manager_user: models_users.CoreUser structure2_manager_user_token: str -structure_admin_user: models_users.CoreUser -structure_admin_user_token: str -structure_admin_to_add: models_users.CoreUser -structure_admin_to_add_token: str ecl_user: models_users.CoreUser ecl_user_access_token: str @@ -66,6 +77,8 @@ ecl_user2_wallet_device: models_mypayment.WalletDevice ecl_user2_payment: models_mypayment.UserPayment +core_association_group: models_groups.CoreGroup + association_membership: models_memberships.CoreAssociationMembership association_membership_user: models_memberships.CoreAssociationUserMembership structure: models_mypayment.Structure @@ -76,6 +89,7 @@ store3: models_mypayment.Store store_wallet_device_private_key: Ed25519PrivateKey store_wallet_device: models_mypayment.WalletDevice +store_direct_transfer: models_mypayment.Transfer transaction_from_ecl_user_to_store: models_mypayment.Transaction @@ -92,6 +106,10 @@ invoice2_detail: models_mypayment.InvoiceDetail invoice3_detail: models_mypayment.InvoiceDetail +proposed_request: models_mypayment.Request +expired_request: models_mypayment.Request +refused_request: models_mypayment.Request + store_seller_can_bank_user: models_users.CoreUser store_seller_no_permission_user_access_token: str store_seller_can_bank_user_access_token: str @@ -107,16 +125,30 @@ @pytest_asyncio.fixture(scope="module", autouse=True) async def init_objects() -> None: + for account_type in AccountType: + await add_object_to_db( + models_permissions.CorePermissionAccountType( + permission_name=MyPaymentPermissions.access_mypayment.value, + account_type=account_type, + ), + ) + global bde_group bde_group = await create_groups_with_permissions( [], - "BDE Group", + "Payment BDE Group", ) global admin_user, admin_user_token admin_user = await create_user_with_groups(groups=[GroupType.admin]) admin_user_token = create_api_access_token(admin_user) + global core_association_group + core_association_group = await create_groups_with_permissions( + group_name="Core Association Group", + permissions=[], + ) + global association_membership association_membership = models_memberships.CoreAssociationMembership( id=uuid4(), @@ -131,18 +163,10 @@ async def init_objects() -> None: structure, \ structure2_manager_user, \ structure2_manager_user_token, \ - structure2, \ - structure_admin_user, \ - structure_admin_user_token, \ - structure_admin_to_add, \ - structure_admin_to_add_token + structure2 structure_manager_user = await create_user_with_groups(groups=[]) structure_manager_user_token = create_api_access_token(structure_manager_user) - structure_admin_user = await create_user_with_groups(groups=[]) - structure_admin_user_token = create_api_access_token(structure_admin_user) - structure_admin_to_add = await create_user_with_groups(groups=[]) - structure_admin_to_add_token = create_api_access_token(structure_admin_to_add) structure = models_mypayment.Structure( id=uuid4(), @@ -161,13 +185,6 @@ async def init_objects() -> None: ) await add_object_to_db(structure) - await add_object_to_db( - models_mypayment.StructureAdministrator( - structure_id=structure.id, - user_id=structure_admin_user.id, - ), - ) - await add_coredata_to_db( MyPaymentBankAccountHolder( holder_structure_id=structure.id, @@ -318,7 +335,7 @@ async def init_objects() -> None: ) await add_object_to_db(store3_wallet) - global store, store2 + global store, store2, store3 store = models_mypayment.Store( id=uuid4(), wallet_id=store_wallet.id, @@ -335,6 +352,7 @@ async def init_objects() -> None: creation=datetime.now(UTC), ) await add_object_to_db(store2) + store3 = models_mypayment.Store( id=uuid4(), wallet_id=store3_wallet.id, @@ -351,6 +369,7 @@ async def init_objects() -> None: can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, ) await add_object_to_db(manager_as_admin) @@ -371,6 +390,21 @@ async def init_objects() -> None: ) await add_object_to_db(store_wallet_device) + global store_direct_transfer + store_direct_transfer = models_mypayment.Transfer( + id=uuid4(), + origin=TransferOrigin.HELLO_ASSO, + transfer_identifier=str(uuid4()), + approver_user_id=None, + wallet_id=store_wallet.id, + total=1500, # 15€ + creation=datetime.now(UTC), + confirmed=False, + module=TEST_MODULE_ROOT, + object_id=uuid4(), + ) + await add_object_to_db(store_direct_transfer) + # Create test transactions global transaction_from_ecl_user_to_store transaction_from_ecl_user_to_store = models_mypayment.Transaction( @@ -442,13 +476,15 @@ async def init_objects() -> None: global ecl_user_transfer ecl_user_transfer = models_mypayment.Transfer( id=uuid4(), - type=TransferType.HELLO_ASSO, + origin=TransferOrigin.HELLO_ASSO, transfer_identifier="transfer_identifier", approver_user_id=None, wallet_id=ecl_user_wallet.id, total=1000, # 10€ creation=datetime.now(UTC), confirmed=True, + module=None, + object_id=None, ) await add_object_to_db(ecl_user_transfer) @@ -479,6 +515,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(store_seller_no_permission) @@ -494,8 +531,9 @@ async def init_objects() -> None: store_id=store.id, can_bank=True, can_see_history=False, - can_cancel=False, + can_cancel=True, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(store_seller_can_bank) @@ -513,6 +551,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=True, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(store_seller_can_cancel) @@ -530,6 +569,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=False, can_manage_sellers=True, + can_manage_events=True, ) await add_object_to_db(store_seller_can_manage_sellers) @@ -544,6 +584,7 @@ async def init_objects() -> None: can_see_history=True, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(store_seller_can_see_history_seller) store_seller_can_see_history_user_access_token = create_api_access_token( @@ -618,6 +659,50 @@ async def init_objects() -> None: ) await add_object_to_db(invoice3_detail) + global proposed_request, expired_request, refused_request + proposed_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + total=1000, + name="Proposed Request", + store_note="Proposed Request Note", + status=RequestStatus.PROPOSED, + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + creation=datetime.now(UTC), + ) + await add_object_to_db(proposed_request) + expired_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + total=1000, + name="Expired Request", + store_note="Expired Request Note", + status=RequestStatus.PROPOSED, + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + creation=datetime.now(UTC) - timedelta(days=30), + ) + await add_object_to_db(expired_request) + refused_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + total=1000, + name="Refused Request", + store_note="Refused Request Note", + status=RequestStatus.REFUSED, + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + creation=datetime.now(UTC) - timedelta(days=30), + ) + await add_object_to_db(refused_request) + async def test_get_structures(client: TestClient): response = client.get( @@ -626,12 +711,6 @@ async def test_get_structures(client: TestClient): ) assert response.status_code == 200 assert len(response.json()) == 2 - structure_from_response = next( - s for s in response.json() if s["id"] == str(structure.id) - ) - assert structure_from_response["administrators"][0]["id"] == str( - structure_admin_user.id, - ) async def test_create_structure(client: TestClient): @@ -730,82 +809,6 @@ async def test_delete_structure_as_admin(client: TestClient): assert response.status_code == 204 -async def test_add_structure_administrator_as_manager(client: TestClient): - response = client.post( - f"/mypayment/structures/{structure.id}/admin/{structure_admin_to_add.id}", - headers={"Authorization": f"Bearer {structure_manager_user_token}"}, - ) - assert response.status_code == 201 - - response = client.get( - "/mypayment/structures", - headers={"Authorization": f"Bearer {ecl_user_access_token}"}, - ) - assert response.status_code == 200 - structure_from_response = next( - s for s in response.json() if s["id"] == str(structure.id) - ) - new_admin = next( - ( - a - for a in structure_from_response["administrators"] - if a["id"] == str(structure_admin_to_add.id) - ), - None, - ) - assert new_admin is not None - - sellers = client.get( - "/mypayment/users/me/stores", - headers={"Authorization": f"Bearer {structure_admin_to_add_token}"}, - ) - assert sellers.status_code == 200 - assert len(sellers.json()) == 1 - assert sellers.json()[0]["id"] == str(store.id) - assert sellers.json()[0]["can_bank"] is True - assert sellers.json()[0]["can_cancel"] is True - assert sellers.json()[0]["can_manage_sellers"] is True - assert sellers.json()[0]["can_see_history"] is True - - -async def test_delete_structure_administrator_as_manager(client: TestClient): - response = client.delete( - f"/mypayment/structures/{structure.id}/admin/{structure_admin_to_add.id}", - headers={"Authorization": f"Bearer {structure_manager_user_token}"}, - ) - assert response.status_code == 204 - - response = client.get( - "/mypayment/structures", - headers={"Authorization": f"Bearer {ecl_user_access_token}"}, - ) - assert response.status_code == 200 - structure_from_response = next( - s for s in response.json() if s["id"] == str(structure.id) - ) - new_admin = next( - ( - a - for a in structure_from_response["administrators"] - if a["id"] == str(structure_admin_to_add.id) - ), - None, - ) - assert new_admin is None - - sellers = client.get( - "/mypayment/users/me/stores", - headers={"Authorization": f"Bearer {structure_admin_to_add_token}"}, - ) - assert sellers.status_code == 200 - assert len(sellers.json()) == 1 - assert sellers.json()[0]["id"] == str(store.id) - assert sellers.json()[0]["can_bank"] is True - assert sellers.json()[0]["can_cancel"] is False - assert sellers.json()[0]["can_manage_sellers"] is False - assert sellers.json()[0]["can_see_history"] is True - - async def test_transfer_non_existing_structure_manager(client: TestClient): response = client.post( f"/mypayment/structures/{uuid4()}/init-manager-transfer", @@ -922,6 +925,7 @@ async def test_transfer_structure_manager_as_manager( can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) @@ -1055,13 +1059,18 @@ async def test_get_store_history(client: TestClient): assert response.status_code == 200 history_list = response.json() - assert len(history_list) == 2 + assert len(history_list) == 3 history = {transaction["id"]: transaction for transaction in history_list} assert str(transaction_from_store_to_ecl_user.id) in history assert history[str(transaction_from_store_to_ecl_user.id)]["total"] == 700 assert str(transaction_from_ecl_user_to_store.id) in history assert history[str(transaction_from_ecl_user_to_store.id)]["total"] == 500 + assert str(store_direct_transfer.id) in history + assert history[str(store_direct_transfer.id)]["total"] == 1500 + assert history[str(store_direct_transfer.id)]["type"] == "request_transfer" + assert history[str(store_direct_transfer.id)]["direction"] == "credited" + assert history[str(store_direct_transfer.id)]["status"] in ["pending", "canceled"] async def test_get_store_history_with_date(client: TestClient): @@ -1261,7 +1270,7 @@ async def test_get_stores_as_manager(client: TestClient): headers={"Authorization": f"Bearer {structure_manager_user_token}"}, ) assert response.status_code == 200 - assert len(response.json()) > 1 + assert len(response.json()) == 2 async def test_update_store_non_existing(client: TestClient): @@ -1351,6 +1360,7 @@ async def test_delete_store(client: TestClient): can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, ) await add_object_to_db(sellet) @@ -1409,6 +1419,7 @@ async def test_add_seller_for_non_existing_store(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 404 @@ -1425,6 +1436,7 @@ async def test_add_seller_as_lambda(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 403 @@ -1449,6 +1461,7 @@ async def test_add_seller_as_seller_with_permission(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 201 @@ -1469,6 +1482,7 @@ async def test_add_seller_as_seller_without_permission(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 403 @@ -1489,6 +1503,7 @@ async def test_add_already_existing_seller(client: TestClient): can_see_history=True, can_cancel=True, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) @@ -1503,6 +1518,7 @@ async def test_add_already_existing_seller(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 400 @@ -1564,6 +1580,7 @@ async def test_update_seller_of_non_existing_store(client: TestClient): "can_see_history": True, "can_cancel": False, "can_manage_sellers": False, + "can_manage_events": False, }, ) assert response.status_code == 404 @@ -1579,6 +1596,7 @@ async def test_update_seller_as_lambda(client: TestClient): "can_see_history": True, "can_cancel": False, "can_manage_sellers": False, + "can_manage_events": False, }, ) assert response.status_code == 403 @@ -1599,6 +1617,7 @@ async def test_update_seller_as_seller_without_permission(client: TestClient): "can_see_history": False, "can_cancel": False, "can_manage_sellers": False, + "can_manage_events": False, }, ) assert response.status_code == 403 @@ -1619,6 +1638,7 @@ async def test_update_non_existing_seller(client: TestClient): can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) response = client.patch( @@ -1646,6 +1666,7 @@ async def test_update_seller_as_seller_with_permission(client: TestClient): can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) response = client.patch( @@ -1754,6 +1775,7 @@ async def test_delete_seller_as_seller_with_permission(client: TestClient): can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) response = client.delete( @@ -1785,7 +1807,7 @@ async def test_get_tos_for_unregistered_user(client: TestClient): headers={"Authorization": f"Bearer {unregistered_ecl_user_access_token}"}, ) assert response.status_code == 400 - assert response.json()["detail"] == "User is not registered for MyECL Pay" + assert response.json()["detail"] == "User is not registered for MyPayment" async def test_get_user_tos(client: TestClient): @@ -1815,7 +1837,7 @@ async def test_register_new_user(client: TestClient): headers={"Authorization": f"Bearer {user_to_register_token}"}, ) assert response.status_code == 400 - assert response.json()["detail"] == "User is already registered for MyECL Pay" + assert response.json()["detail"] == "User is already registered for MyPayment" async def test_sign_tos_for_old_tos_version(client: TestClient): @@ -1835,7 +1857,7 @@ async def test_sign_tos_for_unregistered_user(client: TestClient): json={"accepted_tos_version": LATEST_TOS}, ) assert response.status_code == 400 - assert response.json()["detail"] == "User is not registered for MyECL Pay" + assert response.json()["detail"] == "User is not registered for MyPayment" async def test_sign_tos(client: TestClient): @@ -1864,7 +1886,7 @@ async def test_get_user_devices_with_unregistred_user(client: TestClient): headers={"Authorization": f"Bearer {unregistered_ecl_user_access_token}"}, ) assert response.status_code == 400 - assert response.json()["detail"] == "User is not registered for MyECL Pay" + assert response.json()["detail"] == "User is not registered for MyPayment" async def test_get_user_devices(client: TestClient): @@ -1882,7 +1904,7 @@ async def test_get_user_wallet_unregistred_user(client: TestClient): headers={"Authorization": f"Bearer {unregistered_ecl_user_access_token}"}, ) assert response.status_code == 400 - assert response.json()["detail"] == "User is not registered for MyECL Pay" + assert response.json()["detail"] == "User is not registered for MyPayment" async def test_get_user_wallet(client: TestClient): @@ -1900,7 +1922,7 @@ async def test_get_user_device_non_existing_user(client: TestClient): headers={"Authorization": f"Bearer {unregistered_ecl_user_access_token}"}, ) assert response.status_code == 400 - assert response.json()["detail"] == "User is not registered for MyECL Pay" + assert response.json()["detail"] == "User is not registered for MyPayment" async def test_get_user_device_non_existing_device(client: TestClient): @@ -1945,7 +1967,7 @@ async def test_create_user_device_unregistred_user(client: TestClient): }, ) assert response.status_code == 400 - assert response.json()["detail"] == "User is not registered for MyECL Pay" + assert response.json()["detail"] == "User is not registered for MyPayment" async def test_create_and_activate_user_device( @@ -2036,7 +2058,7 @@ async def test_revoke_user_device_unregistered_user( headers={"Authorization": f"Bearer {unregistered_ecl_user_access_token}"}, ) assert response.status_code == 400 - assert response.json()["detail"] == "User is not registered for MyECL Pay" + assert response.json()["detail"] == "User is not registered for MyPayment" async def test_revoke_user_device_device_does_not_exist( @@ -2100,7 +2122,7 @@ async def test_get_transactions_unregistered(client: TestClient): ) assert response.status_code == 404 - assert response.json()["detail"] == "User is not registered for MyECL Pay" + assert response.json()["detail"] == "User is not registered for MyPayment" def test_get_transactions_success(client: TestClient): @@ -2119,7 +2141,10 @@ def test_get_transactions_success(client: TestClient): transactions_dict[transaction_from_ecl_user_to_store.id]["other_wallet_name"] == "Test Store" ) - assert transactions_dict[transaction_from_ecl_user_to_store.id]["type"] == "given" + assert ( + transactions_dict[transaction_from_ecl_user_to_store.id]["direction"] + == "debited" + ) assert transactions_dict[transaction_from_ecl_user_to_store.id]["total"] == 500 assert ( transactions_dict[transaction_from_ecl_user_to_store.id]["status"] @@ -2133,7 +2158,8 @@ def test_get_transactions_success(client: TestClient): == "firstname ECL User 2 (nickname)" ) assert ( - transactions_dict[transaction_from_ecl_user_to_ecl_user2.id]["type"] == "given" + transactions_dict[transaction_from_ecl_user_to_ecl_user2.id]["direction"] + == "debited" ) assert transactions_dict[transaction_from_ecl_user_to_ecl_user2.id]["total"] == 600 assert ( @@ -2146,7 +2172,12 @@ def test_get_transactions_success(client: TestClient): == "Test Store" ) assert ( - transactions_dict[transaction_from_store_to_ecl_user.id]["type"] == "received" + transactions_dict[transaction_from_store_to_ecl_user.id]["type"] + == "direct_transaction" + ) + assert ( + transactions_dict[transaction_from_store_to_ecl_user.id]["direction"] + == "credited" ) assert transactions_dict[transaction_from_store_to_ecl_user.id]["total"] == 700 assert ( @@ -2162,7 +2193,11 @@ def test_get_transactions_success(client: TestClient): ) assert ( transactions_dict[transaction_from_ecl_user2_to_ecl_user.id]["type"] - == "received" + == "direct_transaction" + ) + assert ( + transactions_dict[transaction_from_ecl_user2_to_ecl_user.id]["direction"] + == "credited" ) assert transactions_dict[transaction_from_ecl_user2_to_ecl_user.id]["total"] == 800 assert ( @@ -2194,7 +2229,12 @@ def test_get_transactions_success_with_date(client: TestClient): == "firstname ECL User 2 (nickname)" ) assert ( - transactions_dict[transaction_from_ecl_user_to_ecl_user2.id]["type"] == "given" + transactions_dict[transaction_from_ecl_user_to_ecl_user2.id]["type"] + == "direct_transaction" + ) + assert ( + transactions_dict[transaction_from_ecl_user_to_ecl_user2.id]["direction"] + == "debited" ) assert transactions_dict[transaction_from_ecl_user_to_ecl_user2.id]["total"] == 600 assert ( @@ -2207,7 +2247,12 @@ def test_get_transactions_success_with_date(client: TestClient): == "Test Store" ) assert ( - transactions_dict[transaction_from_store_to_ecl_user.id]["type"] == "received" + transactions_dict[transaction_from_store_to_ecl_user.id]["type"] + == "direct_transaction" + ) + assert ( + transactions_dict[transaction_from_store_to_ecl_user.id]["direction"] + == "credited" ) assert transactions_dict[transaction_from_store_to_ecl_user.id]["total"] == 700 assert ( @@ -2243,7 +2288,7 @@ def test_transfer_with_unregistered_user(client: TestClient): ) assert response.status_code == 404 - assert response.json()["detail"] == "User is not registered for MyECL Pay" + assert response.json()["detail"] == "User is not registered for MyPayment" def test_transfer_with_too_small_amount(client: TestClient): @@ -2296,7 +2341,7 @@ def test_hello_asso_transfer( assert response.json()["url"] == "https://some.url.fr/checkout" response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": {"amount": 1000, "id": 123}, @@ -2490,7 +2535,7 @@ def test_store_scan_store_invalid_signature(client: TestClient): def test_store_scan_store_with_non_store_qr_code(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=-1, iat=datetime.now(UTC), @@ -2525,7 +2570,7 @@ def test_store_scan_store_with_non_store_qr_code(client: TestClient): def test_store_scan_store_negative_total(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=-1, iat=datetime.now(UTC), @@ -2567,7 +2612,7 @@ def test_store_scan_store_missing_wallet( qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=100, iat=datetime.now(UTC), @@ -2603,7 +2648,7 @@ def test_store_scan_store_missing_wallet( def test_store_scan_store_from_store_wallet(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=1100, iat=datetime.now(UTC), @@ -2673,7 +2718,7 @@ async def test_store_scan_store_from_wallet_with_old_tos_version(client: TestCli qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=1100, iat=datetime.now(UTC), @@ -2706,7 +2751,7 @@ async def test_store_scan_store_from_wallet_with_old_tos_version(client: TestCli def test_store_scan_store_insufficient_ballance(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=3000, iat=datetime.now(UTC), @@ -2739,7 +2784,7 @@ def test_store_scan_store_insufficient_ballance(client: TestClient): async def test_store_scan_store_successful_scan(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=500, iat=datetime.now(UTC), @@ -3047,6 +3092,37 @@ async def test_transaction_refund_partial(client: TestClient): ) +async def test_cancel_transaction( + client: TestClient, +): + recent_transaction = models_mypayment.Transaction( + id=uuid4(), + debited_wallet_id=ecl_user_wallet.id, + credited_wallet_id=store_wallet.id, + total=100, + status=TransactionStatus.CONFIRMED, + creation=datetime.now(UTC), + transaction_type=TransactionType.DIRECT, + seller_user_id=store_seller_can_bank_user.id, + debited_wallet_device_id=ecl_user_wallet_device.id, + store_note="", + qr_code_id=None, + ) + await add_object_to_db(recent_transaction) + response = client.post( + f"/mypayment/transactions/{recent_transaction.id}/cancel", + headers={"Authorization": f"Bearer {store_seller_can_bank_user_access_token}"}, + ) + assert response.status_code == 204, response.text + async with get_TestingSessionLocal()() as db: + transaction_after_cancel = await cruds_mypayment.get_transaction( + db=db, + transaction_id=recent_transaction.id, + ) + assert transaction_after_cancel is not None + assert transaction_after_cancel.status == TransactionStatus.CANCELED + + async def test_get_invoices_as_random_user(client: TestClient): response = client.get( "/mypayment/invoices", @@ -3312,3 +3388,503 @@ async def test_delete_invoice( ) assert response.status_code == 200 assert not any(invoice["id"] == invoice3.id for invoice in response.json()) + + +async def mypayment_callback( + object_id: UUID, + db: AsyncSession, +) -> None: + pass + + +async def test_get_request( + client: TestClient, +): + response = client.get( + "/mypayment/requests", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["id"] == str(proposed_request.id) + + +async def test_get_request_with_used_filter( + client: TestClient, +): + response = client.get( + "/mypayment/requests?used=true", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) == 3 + + +async def test_accept_request_with_invalid_signature( + client: TestClient, +): + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=SignedContent( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + signature="invalid signature", + ).model_dump(mode="json"), + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid signature" + + +async def test_accept_request_with_wrong_wallet_device( + client: TestClient, +): + wrong_wallet_device_private_key = Ed25519PrivateKey.generate() + wrong_wallet_device = models_mypayment.WalletDevice( + id=uuid4(), + name="Wrong device", + wallet_id=ecl_user2_wallet.id, + ed25519_public_key=wrong_wallet_device_private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ), + creation=datetime.now(UTC), + status=WalletDeviceStatus.ACTIVE, + activation_token=str(uuid4()), + ) + await add_object_to_db(wrong_wallet_device) + + validation_data = SecuredContentData( + id=proposed_request.id, + key=wrong_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = wrong_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Wallet device is not associated with the user wallet" + ) + + +async def test_accept_request_with_wrong_user( + client: TestClient, +): + validation_data = SecuredContentData( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user2_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not allowed to confirm this request" + + +async def test_accept_request_with_different_total( + client: TestClient, +): + validation_data = SecuredContentData( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total + 100, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Request total in the body do not match the request total in the database" + ) + + +async def test_accept_request_with_inexistant_wallet_device( + client: TestClient, +): + validation_data = SecuredContentData( + id=proposed_request.id, + key=uuid4(), + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Wallet device does not exist" + + +async def test_accept_request_with_wallet_device_linked_to_another_wallet( + client: TestClient, +): + other_wallet = models_mypayment.Wallet( + id=uuid4(), + type=WalletType.USER, + balance=1000, + ) + await add_object_to_db(other_wallet) + + other_wallet_device_private_key = Ed25519PrivateKey.generate() + other_wallet_device = models_mypayment.WalletDevice( + id=uuid4(), + name="Other device", + wallet_id=other_wallet.id, + ed25519_public_key=other_wallet_device_private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ), + creation=datetime.now(UTC), + status=WalletDeviceStatus.ACTIVE, + activation_token=str(uuid4()), + ) + await add_object_to_db(other_wallet_device) + + validation_data = SecuredContentData( + id=proposed_request.id, + key=other_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = other_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Wallet device is not associated with the user wallet" + ) + + +async def test_accept_request_with_non_proposed_request( + client: TestClient, +): + non_proposed_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.ACCEPTED, + creation=datetime.now(UTC), + ) + await add_object_to_db(non_proposed_request) + + validation_data = SecuredContentData( + id=non_proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=non_proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{non_proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Only pending requests can be confirmed" + + +async def test_accept_request( + mocker: MockerFixture, + client: TestClient, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + validation_data = SecuredContentData( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 204 + + responser = client.get( + "/mypayment/requests?used=true", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert responser.status_code == 200 + accepted = next( + ( + request + for request in responser.json() + if request["id"] == str(proposed_request.id) + ), + None, + ) + assert accepted is not None + assert accepted["status"] == RequestStatus.ACCEPTED + mocked_callback.assert_called_once() + + +async def test_accept_expired_request( + client: TestClient, + mocker: MockerFixture, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + expired_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.PROPOSED, + creation=datetime.now(UTC) - timedelta(minutes=REQUEST_EXPIRATION + 1), + ) + await add_object_to_db(expired_request) + + validation_data = SecuredContentData( + id=expired_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=expired_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{expired_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Request is expired" + + mocked_callback.assert_not_called() + + +async def test_refuse_request( + client: TestClient, + mocker: MockerFixture, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + new_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.PROPOSED, + creation=datetime.now(UTC), + ) + await add_object_to_db(new_request) + + response = client.post( + f"/mypayment/requests/{new_request.id}/refuse", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 204 + + mocked_callback.assert_not_called() + + responser = client.get( + "/mypayment/requests?used=true", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert responser.status_code == 200 + refused = next( + ( + request + for request in responser.json() + if request["id"] == str(new_request.id) + ), + None, + ) + assert refused is not None + assert refused["status"] == RequestStatus.REFUSED + + +async def test_direct_transfer_callback( + mocker: MockerFixture, + client: TestClient, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + async with get_TestingSessionLocal()() as db: + await validate_transfer_callback( + checkout_payment=schemas_checkout.CheckoutPayment( + id=uuid4(), + paid_amount=1500, + checkout_id=UUID(store_direct_transfer.transfer_identifier), + ), + db=db, + ) + + mocked_callback.assert_called_once() + + +async def test_integrity_check( + client: TestClient, +): + response = client.get( + "/mypayment/integrity-check", + headers={"x-data-verifier-token": "test_data_verifier_access_token"}, + ) + assert response.status_code == 200, response.text diff --git a/tests/core/test_user_fusion.py b/tests/core/test_user_fusion.py index fd967e825a..1290cb493f 100644 --- a/tests/core/test_user_fusion.py +++ b/tests/core/test_user_fusion.py @@ -5,10 +5,12 @@ from fastapi.testclient import TestClient from app.core.groups import models_groups -from app.core.groups.groups_type import GroupType +from app.core.groups.groups_type import AccountType, GroupType from app.core.memberships import models_memberships from app.core.mypayment import models_mypayment +from app.core.mypayment.endpoints_mypayment import MyPaymentPermissions from app.core.mypayment.types_mypayment import WalletType +from app.core.permissions import models_permissions from app.core.users import models_users from tests.commons import ( add_object_to_db, @@ -43,6 +45,12 @@ @pytest_asyncio.fixture(scope="module", autouse=True) async def init_objects() -> None: + await add_object_to_db( + models_permissions.CorePermissionAccountType( + account_type=AccountType.student, + permission_name=MyPaymentPermissions.access_mypayment, + ), + ) global group1, group2 group1 = models_groups.CoreGroup( id=str(uuid4()), @@ -210,7 +218,7 @@ def test_fusion_users(client: TestClient) -> None: "/mypayment/users/me/wallet", headers={"Authorization": f"Bearer {token_student_user}"}, ) - assert response.status_code == 200 + assert response.status_code == 200, response.text wallet = response.json() assert wallet["id"] == str(payment_wallet_to_keep.id) assert wallet["balance"] == 3000 diff --git a/tests/modules/cdr/test_cdr.py b/tests/modules/cdr/test_cdr.py index a0b04b4a2f..c5f89d8272 100644 --- a/tests/modules/cdr/test_cdr.py +++ b/tests/modules/cdr/test_cdr.py @@ -2298,7 +2298,7 @@ async def test_pay(mocker: MockerFixture, client: TestClient): assert response.json()["url"] == "https://some.url.fr/checkout" response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": {"amount": 500, "id": 123}, diff --git a/tests/modules/sport_competition/test_purchases.py b/tests/modules/sport_competition/test_purchases.py index 0c7510e917..4fe8ca7420 100644 --- a/tests/modules/sport_competition/test_purchases.py +++ b/tests/modules/sport_competition/test_purchases.py @@ -5,8 +5,8 @@ import pytest_asyncio from fastapi.testclient import TestClient +from app.core.checkout import models_checkout from app.core.groups import models_groups -from app.core.payment import models_payment from app.core.schools import models_schools from app.core.schools.schools_type import SchoolType from app.core.users import models_users @@ -538,7 +538,7 @@ async def setup(): created_at=datetime.now(UTC), ) await add_object_to_db(payment) - base_checkout = models_payment.Checkout( + base_checkout = models_checkout.Checkout( id=uuid4(), module="competition", name="Competition Checkout", @@ -1489,7 +1489,7 @@ async def test_pay(client: TestClient): assert response.json()["url"] == "https://some.url.fr/checkout" response = client.post( - "/payment/helloasso/webhook", + "/checkout/helloasso/webhook", json={ "eventType": "Payment", "data": {"amount": 800, "id": 123},