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/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 75% rename from app/core/payment/endpoints_payment.py rename to app/core/checkout/endpoints_checkout.py index 9f31849f3e..43001fb852 100644 --- a/app/core/payment/endpoints_payment.py +++ b/app/core/checkout/endpoints_checkout.py @@ -8,8 +8,8 @@ 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 @@ -48,7 +48,7 @@ async def webhook( ) if content.metadata: checkout_metadata = ( - schemas_payment.HelloAssoCheckoutMetadata.model_validate( + schemas_checkout.HelloAssoCheckoutMetadata.model_validate( content.metadata, ) ) @@ -74,7 +74,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 +92,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 +116,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 +136,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/payment_tool.py b/app/core/checkout/payment_tool.py similarity index 93% rename from app/core/payment/payment_tool.py rename to app/core/checkout/payment_tool.py index a2f4a95e72..8f2d0386f2 100644 --- a/app/core/payment/payment_tool.py +++ b/app/core/checkout/payment_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 from app.core.users import schemas_users from app.core.utils import security from app.types.exceptions import ( @@ -132,7 +132,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 @@ -182,7 +182,7 @@ async def init_checkout( return_url=redirection_uri, contains_donation=False, payer=payer, - metadata=schemas_payment.HelloAssoCheckoutMetadata( + metadata=schemas_checkout.HelloAssoCheckoutMetadata( secret=secret, hyperion_checkout_id=str(checkout_model_id), ).model_dump(), @@ -196,7 +196,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 @@ -223,7 +223,7 @@ async def init_checkout( ) if response and response.id: - checkout_model = models_payment.Checkout( + checkout_model = models_checkout.Checkout( id=checkout_model_id, module=module, name=checkout_name, @@ -232,11 +232,11 @@ 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", @@ -257,8 +257,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,10 +267,10 @@ 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( self, 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/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..5a97086983 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,6 +10,8 @@ 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, @@ -761,6 +763,8 @@ async def get_transfers( total=transfer.total, creation=transfer.creation, confirmed=transfer.confirmed, + module=transfer.module, + object_id=transfer.object_id, ) for transfer in result.scalars().all() ] @@ -779,6 +783,8 @@ async def create_transfer( total=transfer.total, creation=transfer.creation, confirmed=transfer.confirmed, + module=transfer.module, + object_id=transfer.object_id, ) db.add(transfer_db) @@ -799,7 +805,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 +820,21 @@ async def get_transfers_by_wallet_id( else and_(True), ), ) - return result.scalars().all() + return [ + schemas_mypayment.Transfer( + id=transfer.id, + type=transfer.type, + 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 +876,36 @@ 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, + 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 +1049,147 @@ 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, + models_mypayment.Request.status == RequestStatus.PROPOSED + 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, + 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: + result = await db.execute( + select(models_mypayment.Request).where( + models_mypayment.Request.id == request_id, + ), + ) + request = result.scalars().first() + return ( + schemas_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, + 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, + 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 mark_expired_requests_as_expired( + db: AsyncSession, +) -> None: + await db.execute( + update(models_mypayment.Request) + .where( + models_mypayment.Request.status == RequestStatus.PROPOSED, + models_mypayment.Request.creation + <= datetime.now(tz=UTC) - timedelta(minutes=REQUEST_EXPIRATION), + ) + .values(status=RequestStatus.EXPIRED), + ) + + +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 7e2f0f318b..cb4111cfa9 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -21,6 +21,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.auth import schemas_auth +from app.core.checkout import schemas_checkout +from app.core.checkout.payment_tool import PaymentTool +from app.core.checkout.types_checkout import HelloAssoConfigName from app.core.core_endpoints import cruds_core from app.core.groups.groups_type import GroupType from app.core.memberships.utils_memberships import ( @@ -32,37 +35,46 @@ 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, + REQUEST_EXPIRATION, + RETENTION_DURATION, HistoryType, + MyPaymentCallType, + RequestStatus, TransactionStatus, TransactionType, 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_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 @@ -76,7 +88,7 @@ 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 @@ -94,12 +106,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, ) @@ -107,13 +125,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", @@ -913,18 +924,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( @@ -944,6 +949,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, ) @@ -982,7 +988,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. @@ -1412,7 +1420,9 @@ 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. @@ -1469,7 +1479,9 @@ async def register_user( ) 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), ): """ @@ -1504,7 +1516,9 @@ 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), ): @@ -1566,7 +1580,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. @@ -1598,7 +1614,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. @@ -1643,7 +1661,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. @@ -1684,7 +1704,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), ): @@ -1897,7 +1919,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, ): @@ -2021,13 +2045,15 @@ 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), @@ -2121,17 +2147,19 @@ async def init_ha_transfer( 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( @@ -2182,7 +2210,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. @@ -2269,7 +2297,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), @@ -2448,56 +2476,27 @@ 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, + settings=settings, ) - 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 @@ -2509,7 +2508,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), ): @@ -2701,7 +2700,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), @@ -2830,6 +2829,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.RequestValidation, + 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** + """ + await cruds_mypayment.mark_expired_requests_as_expired( + db=db, + ) + await db.flush() + if request_id != request_validation.request_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", + ) + + 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 request.status != RequestStatus.PROPOSED: + raise HTTPException( + status_code=400, + detail="Only pending requests can be confirmed", + ) + if request.creation < datetime.now(UTC) - timedelta(minutes=REQUEST_EXPIRATION): + raise HTTPException( + status_code=400, + detail="Request is expired", + ) + + 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 HTTPException( + status_code=500, + detail="Store linked to the request does not exist", + ) + 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, + settings=settings, + 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=MyPaymentCallType.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], diff --git a/app/core/mypayment/exceptions_mypayment.py b/app/core/mypayment/exceptions_mypayment.py index 1d639cc02d..7d57437a7d 100644 --- a/app/core/mypayment/exceptions_mypayment.py +++ b/app/core/mypayment/exceptions_mypayment.py @@ -29,3 +29,26 @@ 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", + ) diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index c33e89b282..3c895e2302 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -192,13 +192,14 @@ 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"), @@ -221,6 +222,12 @@ class Transfer(Base): creation: Mapped[datetime] confirmed: Mapped[bool] + # Store transfer can occur when a user ask for a direct payment instead of a payment request. + # In this case, we want to keep the information of module and object that generated the transfer, + # to be able to call the right callback when the transfer is confirmed + module: Mapped[str | None] + object_id: Mapped[UUID | None] + class Seller(Base): __tablename__ = "mypayment_seller" diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 8cd13dbacf..7ff42e381a 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -10,6 +10,7 @@ from app.core.memberships import schemas_memberships from app.core.mypayment.types_mypayment import ( HistoryType, + RequestStatus, TransactionStatus, TransactionType, TransferType, @@ -149,6 +150,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 @@ -259,6 +266,8 @@ class Transfer(BaseModel): total: int # Stored in cents creation: datetime confirmed: bool + module: str | None + object_id: UUID | None class RefundBase(BaseModel): @@ -348,3 +357,45 @@ class Withdrawal(BaseModel): wallet_id: UUID total: int # Stored in cents creation: datetime + + +class Request(BaseModel): + id: UUID + wallet_id: UUID + creation: datetime + total: int # Stored in cents + store_id: UUID + name: str + store_note: str | None = None + module: str + 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 RequestValidationData(BaseModel): + request_id: UUID + key: UUID + iat: datetime + tot: int + + +class RequestValidation(RequestValidationData): + signature: str + + +class RequestInfo(BaseModel): + user_id: str + store_id: UUID + total: int + name: str + note: str | None + module: str + object_id: UUID diff --git a/app/core/mypayment/types_mypayment.py b/app/core/mypayment/types_mypayment.py index c526982b25..7957844cc8 100644 --- a/app/core/mypayment/types_mypayment.py +++ b/app/core/mypayment/types_mypayment.py @@ -1,25 +1,36 @@ -from enum import Enum -from uuid import UUID +from enum import StrEnum +LATEST_TOS = 2 +QRCODE_EXPIRATION = 5 # minutes +REQUEST_EXPIRATION = 15 # 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 = "direct" REQUEST = "request" REFUND = "refund" -class HistoryType(str, Enum): +class HistoryType(StrEnum): TRANSFER = "transfer" RECEIVED = "received" GIVEN = "given" @@ -27,7 +38,7 @@ class HistoryType(str, Enum): REFUND_DEBITED = "refund_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 +52,18 @@ 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 TransferType(StrEnum): HELLO_ASSO = "hello_asso" -class ActionType(str, Enum): +class ActionType(StrEnum): TRANSFER = "transfer" REFUND = "refund" CANCEL = "cancel" @@ -60,24 +72,6 @@ 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 MyPaymentCallType(StrEnum): + TRANSFER = "transfer" + REQUEST = "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_mypayment.py b/app/core/mypayment/utils_mypayment.py index 51fe9b61de..d1dfcb186b 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -1,41 +1,56 @@ import base64 import logging -from uuid import UUID +from datetime import UTC, datetime +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.payment_tool import PaymentTool +from app.core.mypayment import cruds_mypayment, models_mypayment, schemas_mypayment +from app.core.mypayment.exceptions_mypayment import ( + TransferAlreadyConfirmedInCallbackError, + TransferNotFoundByCallbackError, + TransferTotalDontMatchInCallbackError, + WalletNotFoundOnUpdateError, +) from app.core.mypayment.integrity_mypayment import ( + 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, + RequestValidationData, ) from app.core.mypayment.types_mypayment import ( - TransferAlreadyConfirmedInCallbackError, - TransferNotFoundByCallbackError, - TransferTotalDontMatchInCallbackError, + LATEST_TOS, + MYPAYMENT_LOGS_S3_SUBFOLDER, + MYPAYMENT_ROOT, + RETENTION_DURATION, + MyPaymentCallType, + RequestStatus, + TransferType, ) -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 = 2 -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: QRCodeContentData | RequestValidationData, wallet_device_id: UUID, request_id: str, ) -> bool: @@ -99,7 +114,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 +130,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 +167,199 @@ 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=MyPaymentCallType.TRANSFER, + module_root=transfer.module, + object_id=transfer.object_id, + call_id=transfer.id, + db=db, + ) + + +async def request_transaction( + request_info: schemas_mypayment.RequestInfo, + db: AsyncSession, + notification_tool: NotificationTool, + settings: Settings, +) -> UUID: + """ + Create a transaction request for a user from a store. + """ + payment_user = await cruds_mypayment.get_user_payment(request_info.user_id, db) + if not payment_user: + raise HTTPException( + status_code=400, + detail=f"User {request_info.user_id} does not have a payment account", + ) + request_id = uuid4() + await cruds_mypayment.create_request( + db=db, + request=schemas_mypayment.Request( + id=request_id, + wallet_id=payment_user.wallet_id, + creation=datetime.now(UTC), + total=request_info.total, + store_id=request_info.store_id, + name=request_info.name, + store_note=request_info.note, + module=request_info.module, + object_id=request_info.object_id, + status=RequestStatus.PROPOSED, + ), + ) + message = Message( + title=f"💸 Nouvelle demande de paiement - {request_info.name}", + content=f"Une nouvelle demande de paiement de {request_info.total / 100} € attend votre validation", + action_module=settings.school.payment_name, + ) + await notification_tool.send_notification_to_user( + user_id=request_info.user_id, + message=message, + ) + return request_id + + +async def request_store_transfer( + user: schemas_users.CoreUser, + transfer_info: schemas_mypayment.StoreTransferInfo, + db: AsyncSession, + payment_tool: PaymentTool, + settings: Settings, +) -> schemas_checkout.PaymentUrl: + """ + Create a direct transfer to a store + """ + 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.Transfer( + id=uuid4(), + type=TransferType.HELLO_ASSO, + approver_user_id=None, + 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_checkout.PaymentUrl( + url=checkout.payment_url, + ) + + +async def apply_transaction( + user_id: str, + transaction: schemas_mypayment.TransactionBase, + debited_wallet_device: models_mypayment.WalletDevice, + store: models_mypayment.Store, + settings: Settings, + 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=settings.school.payment_name, + ) + await notification_tool.send_notification_to_user( + user_id=user_id, + message=message, + ) + + +async def call_mypayment_callback( + call_type: MyPaymentCallType, + module_root: str, + object_id: UUID, + call_id: UUID, + db: AsyncSession, +): + id_name = "transfer_id" if call_type == MyPaymentCallType.TRANSFER 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", + ) diff --git a/app/core/users/cruds_users.py b/app/core/users/cruds_users.py index 0e958cc106..ca559fe8f1 100644 --- a/app/core/users/cruds_users.py +++ b/app/core/users/cruds_users.py @@ -10,7 +10,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 @@ -326,6 +325,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 8694e3e58a..9a80459dc0 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..6e7e7ca4d9 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -22,9 +22,9 @@ async def get_users(db: AsyncSession = Depends(get_db)): ) from app.core.auth import schemas_auth +from app.core.checkout.payment_tool import PaymentTool +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.permissions import cruds_permissions from app.core.permissions.type_permissions import ModulePermissions from app.core.users import cruds_users, models_users diff --git a/app/modules/cdr/endpoints_cdr.py b/app/modules/cdr/endpoints_cdr.py index d4af676047..0c47b26964 100644 --- a/app/modules/cdr/endpoints_cdr.py +++ b/app/modules/cdr/endpoints_cdr.py @@ -15,11 +15,11 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout.payment_tool import PaymentTool +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 @@ -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, 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..5d69968724 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -7,9 +7,9 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout.payment_tool import PaymentTool +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 ( @@ -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, 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..152c7e9f33 100644 --- a/app/modules/sport_competition/endpoints_sport_competition.py +++ b/app/modules/sport_competition/endpoints_sport_competition.py @@ -7,9 +7,9 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout.payment_tool import PaymentTool +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 @@ -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, ) 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..3406c77371 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): 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..54f055f01d 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.payment_tool import PaymentTool +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 diff --git a/migrations/versions/66-mypayment-extended.py b/migrations/versions/66-mypayment-extended.py new file mode 100644 index 0000000000..b86a0c75d3 --- /dev/null +++ b/migrations/versions/66-mypayment-extended.py @@ -0,0 +1,62 @@ +"""empty message + +Create Date: 2026-03-19 15:49:33.554684 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "46fbbcee7237" +down_revision: str | None = "e58ffcd6b9eb" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +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), + ) + # ### 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") + # ### 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..28825d4dc1 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.payment_tool import PaymentTool +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 @@ -372,10 +372,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 +383,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 +394,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/core/test_payment.py b/tests/core/test_checkout.py similarity index 92% rename from tests/core/test_payment.py rename to tests/core/test_checkout.py index 50058836b1..1d20fa5960 100644 --- a/tests/core/test_payment.py +++ b/tests/core/test_checkout.py @@ -14,9 +14,9 @@ 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.payment_tool import PaymentTool +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 @@ -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", @@ -122,7 +122,7 @@ 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( @@ -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, ) @@ -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,12 +304,12 @@ 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], ) @@ -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,17 +347,17 @@ 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( @@ -442,7 +442,7 @@ async def test_payment_tool_init_checkout( redirect_url=redirect_url, ) mocker.patch( - "app.core.payment.payment_tool.CheckoutApi", + "app.core.checkout.payment_tool.CheckoutApi", return_value=mock_checkout_api, ) @@ -515,7 +515,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.payment_tool.CheckoutApi", return_value=mock_checkout_api, ) @@ -544,7 +544,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" @@ -580,7 +580,7 @@ async def test_payment_tool_init_checkout_fail( ) mocker.patch( - "app.core.payment.payment_tool.CheckoutApi", + "app.core.checkout.payment_tool.CheckoutApi", return_value=mock_checkout_api, ) diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 09e5ca0ca9..1dfc9a06d6 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -10,24 +10,35 @@ ) 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 ( + QRCodeContentData, + RequestValidation, + RequestValidationData, +) from app.core.mypayment.types_mypayment import ( + LATEST_TOS, + RequestStatus, TransactionStatus, TransactionType, TransferType, 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 +48,8 @@ get_TestingSessionLocal, ) +TEST_MODULE_ROOT = "tests" + bde_group: models_groups.CoreGroup admin_user: models_users.CoreUser @@ -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,6 +125,14 @@ @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( [], @@ -371,6 +397,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(), + type=TransferType.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( @@ -449,6 +490,8 @@ async def init_objects() -> None: total=1000, # 10€ creation=datetime.now(UTC), confirmed=True, + module=None, + object_id=None, ) await add_object_to_db(ecl_user_transfer) @@ -618,6 +661,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.EXPIRED, + 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( @@ -3312,3 +3399,130 @@ 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( + 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 = RequestValidationData( + request_id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = RequestValidation( + **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_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() 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/sport_competition/test_purchases.py b/tests/modules/sport_competition/test_purchases.py index 0c7510e917..804c6eed4b 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",