diff --git a/app/core/checkout/endpoints_checkout.py b/app/core/checkout/endpoints_checkout.py index dca3a57b1c..b14b0e1daa 100644 --- a/app/core/checkout/endpoints_checkout.py +++ b/app/core/checkout/endpoints_checkout.py @@ -134,7 +134,7 @@ async def webhook( try: for module in all_modules: if module.root == checkout.module: - if module.payment_callback is None: + if module.checkout_callback is None: hyperion_error_logger.info( f"Payment: module {checkout.module} does not define a payment callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id})", ) @@ -147,7 +147,7 @@ async def webhook( paid_amount=checkout_payment_model.paid_amount, checkout_id=checkout_payment_model.checkout_id, ) - await module.payment_callback(checkout_payment_schema, db) + 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", ) diff --git a/app/core/groups/groups_type.py b/app/core/groups/groups_type.py index bb6adb4486..1ac6514d11 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 a4321adcc5..6a1dde3f98 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -1,5 +1,5 @@ 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 @@ -9,15 +9,19 @@ from app.core.mypayment import models_mypayment, schemas_mypayment from app.core.mypayment.exceptions_mypayment import WalletNotFoundOnUpdateError from app.core.mypayment.types_mypayment import ( + RequestStatus, TransactionStatus, WalletDeviceStatus, WalletType, ) -from app.core.mypayment.utils_mypayment import ( +from app.core.mypayment.utils.models_converter import ( invoice_model_to_schema, refund_model_to_schema, structure_model_to_schema, ) +from app.core.mypayment.utils_mypayment import ( + REQUEST_EXPIRATION, +) from app.core.users import models_users, schemas_users @@ -704,6 +708,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() ] @@ -722,6 +728,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) @@ -742,7 +750,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( @@ -757,7 +765,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( @@ -799,13 +821,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( @@ -949,6 +994,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 405c64ef13..e708d0f799 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -37,34 +37,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 ( 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.models_converter import structure_model_to_schema from app.core.mypayment.utils_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, + apply_transaction, + call_mypayment_callback, is_user_latest_tos_signed, - structure_model_to_schema, validate_transfer_callback, verify_signature, ) from app.core.notification.schemas_notification import Message +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 @@ -78,7 +90,7 @@ get_settings, get_token_data, is_user, - is_user_a_school_member, + is_user_allowed_to, is_user_in, ) from app.types import standard_responses @@ -97,12 +109,18 @@ router = APIRouter(tags=["MyPayment"]) + +class MyPaymentPermissions(ModulePermissions): + access_payment = "access_payment" + + 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, ) @@ -110,13 +128,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", @@ -745,18 +756,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( @@ -776,6 +781,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, ) @@ -814,7 +820,7 @@ 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_payment])), ): """ Get all stores for the current user. @@ -1224,7 +1230,7 @@ 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_payment])), ): """ Sign MyPayment TOS for the given user. @@ -1281,7 +1287,7 @@ 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_payment])), settings: Settings = Depends(get_settings), ): """ @@ -1319,7 +1325,7 @@ 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_payment])), mail_templates: calypsso.MailTemplates = Depends(get_mail_templates), settings: Settings = Depends(get_settings), ): @@ -1381,7 +1387,7 @@ 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_payment])), ): """ Get user devices. @@ -1413,7 +1419,7 @@ 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_payment])), ): """ Get user devices. @@ -1458,7 +1464,7 @@ 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_payment])), ): """ Get user wallet. @@ -1499,7 +1505,7 @@ 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_payment])), mail_templates: calypsso.MailTemplates = Depends(get_mail_templates), settings: Settings = Depends(get_settings), ): @@ -1712,7 +1718,7 @@ 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_payment])), start_date: datetime | None = None, end_date: datetime | None = None, ): @@ -1842,7 +1848,7 @@ async def get_user_wallet_history( 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_payment])), settings: Settings = Depends(get_settings), payment_tool: PaymentTool = Depends( get_payment_tool(HelloAssoConfigName.MYPAYMENT), @@ -1936,6 +1942,8 @@ async def init_ha_transfer( wallet_id=user_payment.wallet_id, creation=datetime.now(UTC), confirmed=False, + module=None, + object_id=None, ), ) @@ -1997,7 +2005,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_a_school_member), + user: CoreUser = Depends(is_user()), ): """ Validate if a given QR Code can be scanned by the seller. @@ -2084,7 +2092,7 @@ async def store_scan_qrcode( store_id: UUID, scan_info: schemas_mypayment.ScanInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_a_school_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), @@ -2263,56 +2271,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 @@ -2324,7 +2303,7 @@ async def refund_transaction( transaction_id: UUID, refund_info: schemas_mypayment.RefundInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_a_school_member), + user: CoreUser = Depends(is_user()), notification_tool: NotificationTool = Depends(get_notification_tool), settings: Settings = Depends(get_settings), ): @@ -2516,7 +2495,7 @@ async def refund_transaction( async def cancel_transaction( transaction_id: UUID, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_a_school_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), @@ -2645,6 +2624,269 @@ 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_payment])), +): + """ + Get all requests made by the user. + + **The user must be authenticated to use this endpoint** + """ + user_payment = await cruds_mypayment.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None: + raise HTTPException( + status_code=404, + detail="User is not registered for MyPayment", + ) + return await cruds_mypayment.get_requests_by_wallet_id( + wallet_id=user_payment.wallet_id, + db=db, + include_used=used or False, + ) + + +@router.post( + "/mypayment/requests/{request_id}/accept", + status_code=204, +) +async def accept_request( + request_id: UUID, + request_validation: schemas_mypayment.SignedContent, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), + 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.id: + raise HTTPException( + status_code=400, + detail="Request ID in the path and in the body do not match", + ) + request = await cruds_mypayment.get_request_by_id( + request_id=request_id, + db=db, + ) + if request is None: + raise HTTPException( + status_code=404, + detail="Request does not exist", + ) + if request.total != request_validation.tot: + raise HTTPException( + status_code=400, + detail="Request total in the body do not match the request total in the database", + ) + + 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_payment])), +): + """ + 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], @@ -3092,7 +3334,7 @@ async def delete_structure_invoice( response_model=schemas_mypayment.IntegrityCheckData, ) async def get_data_for_integrity_check( - headers: schemas_mypayment.IntegrityCheckHeaders = Header(), + headers: schemas_mypayment.IntegrityCheckHeaders = Header(...), query_params: schemas_mypayment.IntegrityCheckQuery = Query(), db: AsyncSession = Depends(get_db), settings: Settings = Depends(get_settings), @@ -3109,7 +3351,7 @@ async def get_data_for_integrity_check( """ if settings.MYPAYMENT_DATA_VERIFIER_ACCESS_TOKEN is None: raise HTTPException( - status_code=301, + status_code=401, detail="MYPAYMENT_DATA_VERIFIER_ACCESS_TOKEN is not set in the settings", ) diff --git a/app/core/mypayment/exceptions_mypayment.py b/app/core/mypayment/exceptions_mypayment.py index 1d639cc02d..ff9d75d510 100644 --- a/app/core/mypayment/exceptions_mypayment.py +++ b/app/core/mypayment/exceptions_mypayment.py @@ -29,3 +29,33 @@ class ReferencedStructureNotFoundError(Exception): def __init__(self, structure_id: UUID): super().__init__(f"Referenced structure {structure_id} not found") + + +class UnexpectedError(Exception): + pass + + +class TransferNotFoundByCallbackError(Exception): + def __init__(self, checkout_id: UUID): + super().__init__(f"User transfer {checkout_id} not found.") + + +class TransferTotalDontMatchInCallbackError(Exception): + def __init__(self, transfer_id: UUID): + super().__init__( + f"User transfer {transfer_id} amount does not match the paid amount", + ) + + +class TransferAlreadyConfirmedInCallbackError(Exception): + def __init__(self, transfer_id: UUID): + super().__init__( + f"User transfer {transfer_id} has already been confirmed", + ) + + +class PaymentUserNotFoundError(Exception): + def __init__(self, user_id: str): + super().__init__( + f"User {user_id} does not have a payment account", + ) diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index b6af28ce2f..0908f7eb8c 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -177,13 +177,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"), @@ -206,6 +207,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 8825a457de..6b4c524382 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Literal from uuid import UUID from pydantic import ( @@ -10,6 +11,8 @@ from app.core.memberships import schemas_memberships from app.core.mypayment.types_mypayment import ( HistoryType, + MyPaymentCallType, + RequestStatus, TransactionStatus, TransactionType, TransferType, @@ -143,6 +146,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 @@ -163,31 +172,6 @@ class History(BaseModel): refund: HistoryRefund | None = None -class QRCodeContentData(BaseModel): - """ - Format of the data stored in the QR code. - - This data will be signed using ed25519 and the private key of the WalletDevice that generated the QR Code. - - id: Unique identifier of the QR Code - tot: Total amount of the transaction, in cents - iat: Generation datetime of the QR Code - key: Id of the WalletDevice that generated the QR Code, will be used to verify the signature - store: If the QR Code is intended to be scanned for a Store Wallet, or for an other user Wallet - """ - - id: UUID - tot: int - iat: datetime - key: UUID - store: bool - - -class ScanInfo(QRCodeContentData): - signature: str - bypass_membership: bool = False - - class WalletBase(BaseModel): id: UUID type: WalletType @@ -253,6 +237,8 @@ class Transfer(BaseModel): total: int # Stored in cents creation: datetime confirmed: bool + module: str | None + object_id: UUID | None class RefundBase(BaseModel): @@ -342,3 +328,71 @@ 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 RequestInfo(BaseModel): + store_id: UUID + total: int + name: str + note: str | None + module: str + object_id: UUID + + +class PaymentInfo(BaseModel): + store_id: UUID + total: int + name: str + note: str | None + module: str + object_id: UUID + redirect_url: str + + +class SecuredContentData(BaseModel): + """ + Format of the data stored in the payment order. + + This data will be signed using ed25519 and the private key of the WalletDevice that generated the payment order + + id: Unique identifier of the payment + tot: Total amount of the transaction, in cents + iat: Generation datetime of the payment order + key: Id of the WalletDevice that generated the payment order, will be used to get the public key to verify the signature + store: If the payment is destined to a store + """ + + id: UUID + tot: int + iat: datetime + key: UUID + store: bool + + +class SignedContent(SecuredContentData): + signature: str + + +class ScanInfo(SignedContent): + bypass_membership: bool = False diff --git a/app/core/mypayment/types_mypayment.py b/app/core/mypayment/types_mypayment.py index 2fce885bc4..21eaa5da4c 100644 --- a/app/core/mypayment/types_mypayment.py +++ b/app/core/mypayment/types_mypayment.py @@ -1,25 +1,24 @@ -from enum import Enum -from uuid import UUID +from enum import StrEnum -class WalletType(str, Enum): +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 +26,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 +40,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" @@ -59,24 +59,6 @@ class ActionType(str, Enum): WITHDRAWAL = "withdrawal" -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/models_converter.py b/app/core/mypayment/utils/models_converter.py new file mode 100644 index 0000000000..b1f33bb91b --- /dev/null +++ b/app/core/mypayment/utils/models_converter.py @@ -0,0 +1,121 @@ +from app.core.memberships import schemas_memberships +from app.core.mypayment import models_mypayment, schemas_mypayment +from app.core.users import schemas_users + + +def structure_model_to_schema( + structure: models_mypayment.Structure, +) -> schemas_mypayment.Structure: + """ + Convert a structure model to a schema. + """ + return schemas_mypayment.Structure( + id=structure.id, + short_id=structure.short_id, + name=structure.name, + association_membership_id=structure.association_membership_id, + association_membership=schemas_memberships.MembershipSimple( + id=structure.association_membership.id, + name=structure.association_membership.name, + manager_group_id=structure.association_membership.manager_group_id, + ) + if structure.association_membership + else None, + manager_user_id=structure.manager_user_id, + manager_user=schemas_users.CoreUserSimple( + id=structure.manager_user.id, + firstname=structure.manager_user.firstname, + name=structure.manager_user.name, + nickname=structure.manager_user.nickname, + account_type=structure.manager_user.account_type, + school_id=structure.manager_user.school_id, + ), + siret=structure.siret, + siege_address_street=structure.siege_address_street, + siege_address_city=structure.siege_address_city, + siege_address_zipcode=structure.siege_address_zipcode, + siege_address_country=structure.siege_address_country, + iban=structure.iban, + bic=structure.bic, + creation=structure.creation, + ) + + +def refund_model_to_schema( + refund: models_mypayment.Refund, +) -> schemas_mypayment.Refund: + """ + Convert a refund model to a schema. + """ + return schemas_mypayment.Refund( + id=refund.id, + transaction_id=refund.transaction_id, + credited_wallet_id=refund.credited_wallet_id, + debited_wallet_id=refund.debited_wallet_id, + total=refund.total, + creation=refund.creation, + seller_user_id=refund.seller_user_id, + transaction=schemas_mypayment.Transaction( + id=refund.transaction.id, + debited_wallet_id=refund.transaction.debited_wallet_id, + credited_wallet_id=refund.transaction.credited_wallet_id, + transaction_type=refund.transaction.transaction_type, + seller_user_id=refund.transaction.seller_user_id, + total=refund.transaction.total, + creation=refund.transaction.creation, + status=refund.transaction.status, + ), + debited_wallet=schemas_mypayment.WalletInfo( + id=refund.debited_wallet.id, + type=refund.debited_wallet.type, + owner_name=refund.debited_wallet.store.name + if refund.debited_wallet.store + else refund.debited_wallet.user.full_name + if refund.debited_wallet.user + else None, + ), + credited_wallet=schemas_mypayment.WalletInfo( + id=refund.credited_wallet.id, + type=refund.credited_wallet.type, + owner_name=refund.credited_wallet.store.name + if refund.credited_wallet.store + else refund.credited_wallet.user.full_name + if refund.credited_wallet.user + else None, + ), + ) + + +def invoice_model_to_schema( + invoice: models_mypayment.Invoice, +) -> schemas_mypayment.Invoice: + """ + Convert an invoice model to a schema. + """ + return schemas_mypayment.Invoice( + id=invoice.id, + reference=invoice.reference, + structure_id=invoice.structure_id, + creation=invoice.creation, + start_date=invoice.start_date, + end_date=invoice.end_date, + total=invoice.total, + paid=invoice.paid, + received=invoice.received, + structure=structure_model_to_schema(invoice.structure), + details=[ + schemas_mypayment.InvoiceDetail( + invoice_id=invoice.id, + store_id=detail.store_id, + total=detail.total, + store=schemas_mypayment.StoreSimple( + id=detail.store.id, + name=detail.store.name, + structure_id=detail.store.structure_id, + wallet_id=detail.store.wallet_id, + creation=detail.store.creation, + ), + ) + for detail in invoice.details + ], + ) diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 01b613f86c..6136484692 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -1,25 +1,39 @@ 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.checkout import schemas_checkout -from app.core.memberships import schemas_memberships +from app.core.checkout.payment_tool import PaymentTool from app.core.mypayment import cruds_mypayment, models_mypayment, schemas_mypayment -from app.core.mypayment.integrity_mypayment import format_transfer_log -from app.core.mypayment.models_mypayment import UserPayment -from app.core.mypayment.schemas_mypayment import ( - QRCodeContentData, -) -from app.core.mypayment.types_mypayment import ( +from app.core.mypayment.exceptions_mypayment import ( + PaymentUserNotFoundError, TransferAlreadyConfirmedInCallbackError, TransferNotFoundByCallbackError, TransferTotalDontMatchInCallbackError, + WalletNotFoundOnUpdateError, +) +from app.core.mypayment.integrity_mypayment import ( + format_transaction_log, + format_transfer_log, +) +from app.core.mypayment.models_mypayment import UserPayment +from app.core.mypayment.schemas_mypayment import SecuredContentData +from app.core.mypayment.types_mypayment import ( + MyPaymentCallType, + RequestStatus, + TransferType, ) +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") @@ -27,14 +41,21 @@ LATEST_TOS = 2 QRCODE_EXPIRATION = 5 # minutes -MYPAYMENT_LOGS_S3_SUBFOLDER = "logs" +REQUEST_EXPIRATION = 15 # minutes RETENTION_DURATION = 10 * 365 # 10 years in days +MYPAYMENT_ROOT = "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" def verify_signature( public_key_bytes: bytes, signature: str, - data: QRCodeContentData, + data: SecuredContentData, wallet_device_id: UUID, request_id: str, ) -> bool: @@ -45,9 +66,9 @@ def verify_signature( loaded_public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_key_bytes) loaded_public_key.verify( base64.decodebytes(signature.encode("utf-8")), - data.model_dump_json(exclude={"signature", "bypass_membership"}).encode( - "utf-8", - ), + data.model_dump_json( + include=set(SecuredContentData.model_fields.keys()), + ).encode("utf-8"), ) except InvalidSignature: hyperion_security_logger.info( @@ -89,6 +110,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.", @@ -118,121 +147,239 @@ 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, + ) -def structure_model_to_schema( - structure: models_mypayment.Structure, -) -> schemas_mypayment.Structure: +async def request_transaction( + user: schemas_users.CoreUser, + request_info: schemas_mypayment.RequestInfo, + db: AsyncSession, + notification_tool: NotificationTool, + settings: Settings, +) -> None: """ - Convert a structure model to a schema. + Create a transaction request for a user from a store. """ - return schemas_mypayment.Structure( - id=structure.id, - short_id=structure.short_id, - name=structure.name, - association_membership_id=structure.association_membership_id, - association_membership=schemas_memberships.MembershipSimple( - id=structure.association_membership.id, - name=structure.association_membership.name, - manager_group_id=structure.association_membership.manager_group_id, - ) - if structure.association_membership - else None, - manager_user_id=structure.manager_user_id, - manager_user=schemas_users.CoreUserSimple( - id=structure.manager_user.id, - firstname=structure.manager_user.firstname, - name=structure.manager_user.name, - nickname=structure.manager_user.nickname, - account_type=structure.manager_user.account_type, - school_id=structure.manager_user.school_id, + payment_user = await cruds_mypayment.get_user_payment(user.id, db) + if not payment_user: + raise PaymentUserNotFoundError(user.id) + await cruds_mypayment.create_request( + db=db, + request=schemas_mypayment.Request( + id=uuid4(), + 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, ), - siret=structure.siret, - siege_address_street=structure.siege_address_street, - siege_address_city=structure.siege_address_city, - siege_address_zipcode=structure.siege_address_zipcode, - siege_address_country=structure.siege_address_country, - iban=structure.iban, - bic=structure.bic, - creation=structure.creation, + ) + 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=user.id, + message=message, ) -def refund_model_to_schema( - refund: models_mypayment.Refund, -) -> schemas_mypayment.Refund: +async def request_store_transfer( + user: schemas_users.CoreUser, + transfer_info: schemas_mypayment.StoreTransferInfo, + db: AsyncSession, + payment_tool: PaymentTool, + settings: Settings, +) -> schemas_checkout.PaymentUrl: """ - Convert a refund model to a schema. + Create a direct transfer to a store """ - return schemas_mypayment.Refund( - id=refund.id, - transaction_id=refund.transaction_id, - credited_wallet_id=refund.credited_wallet_id, - debited_wallet_id=refund.debited_wallet_id, - total=refund.total, - creation=refund.creation, - seller_user_id=refund.seller_user_id, - transaction=schemas_mypayment.Transaction( - id=refund.transaction.id, - debited_wallet_id=refund.transaction.debited_wallet_id, - credited_wallet_id=refund.transaction.credited_wallet_id, - transaction_type=refund.transaction.transaction_type, - seller_user_id=refund.transaction.seller_user_id, - total=refund.transaction.total, - creation=refund.transaction.creation, - status=refund.transaction.status, - ), - debited_wallet=schemas_mypayment.WalletInfo( - id=refund.debited_wallet.id, - type=refund.debited_wallet.type, - owner_name=refund.debited_wallet.store.name - if refund.debited_wallet.store - else refund.debited_wallet.user.full_name - if refund.debited_wallet.user - else None, - ), - credited_wallet=schemas_mypayment.WalletInfo( - id=refund.credited_wallet.id, - type=refund.credited_wallet.type, - owner_name=refund.credited_wallet.store.name - if refund.credited_wallet.store - else refund.credited_wallet.user.full_name - if refund.credited_wallet.user - else None, + 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, + ) -def invoice_model_to_schema( - invoice: models_mypayment.Invoice, -) -> schemas_mypayment.Invoice: - """ - Convert an invoice model to a schema. - """ - return schemas_mypayment.Invoice( - id=invoice.id, - reference=invoice.reference, - structure_id=invoice.structure_id, - creation=invoice.creation, - start_date=invoice.start_date, - end_date=invoice.end_date, - total=invoice.total, - paid=invoice.paid, - received=invoice.received, - structure=structure_model_to_schema(invoice.structure), - details=[ - schemas_mypayment.InvoiceDetail( - invoice_id=invoice.id, - store_id=detail.store_id, - total=detail.total, - store=schemas_mypayment.StoreSimple( - id=detail.store.id, - name=detail.store.name, - structure_id=detail.store.structure_id, - wallet_id=detail.store.wallet_id, - creation=detail.store.creation, - ), - ) - for detail in invoice.details - ], + +async def request_payment( + payment_type: MyPaymentCallType, + payment_info: schemas_mypayment.PaymentInfo, + user: schemas_users.CoreUser, + db: AsyncSession, + payment_tool: PaymentTool, + notification_tool: NotificationTool, + settings: Settings, +) -> None | schemas_checkout.PaymentUrl: + if payment_type == MyPaymentCallType.REQUEST: + return await request_transaction( + user=user, + request_info=schemas_mypayment.RequestInfo( + total=payment_info.total, + store_id=payment_info.store_id, + name=payment_info.name, + note=payment_info.note, + module=payment_info.module, + object_id=payment_info.object_id, + ), + db=db, + notification_tool=notification_tool, + settings=settings, + ) + if payment_type == MyPaymentCallType.TRANSFER: + return await request_store_transfer( + user=user, + transfer_info=schemas_mypayment.StoreTransferInfo( + amount=payment_info.total, + store_id=payment_info.store_id, + module=payment_info.module, + object_id=payment_info.object_id, + redirect_url=payment_info.redirect_url, + ), + db=db, + payment_tool=payment_tool, + settings=settings, + ) + raise HTTPException( + status_code=400, + detail="Invalid payment type", ) + + +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/modules/cdr/endpoints_cdr.py b/app/modules/cdr/endpoints_cdr.py index 0a965d1df3..6778fdc9d7 100644 --- a/app/modules/cdr/endpoints_cdr.py +++ b/app/modules/cdr/endpoints_cdr.py @@ -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/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 b069ccd9f5..ba31f3e0ea 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -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/sport_competition/endpoints_sport_competition.py b/app/modules/sport_competition/endpoints_sport_competition.py index 61e7adfd58..a558840eb8 100644 --- a/app/modules/sport_competition/endpoints_sport_competition.py +++ b/app/modules/sport_competition/endpoints_sport_competition.py @@ -80,7 +80,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/types/module.py b/app/types/module.py index 3b87068e6d..68152a6a8a 100644 --- a/app/types/module.py +++ b/app/types/module.py @@ -1,4 +1,5 @@ from collections.abc import Awaitable, Callable +from uuid import UUID from fastapi import APIRouter from sqlalchemy.ext.asyncio import AsyncSession @@ -17,11 +18,16 @@ def __init__( tag: str, factory: Factory | None, router: APIRouter | None = None, - payment_callback: Callable[ + checkout_callback: Callable[ [schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None], ] | None = None, + mypayment_callback: Callable[ + [UUID, AsyncSession], + Awaitable[None], + ] + | None = None, registred_topics: list[Topic] | None = None, permissions: type[ModulePermissions] | None = None, ): @@ -38,10 +44,13 @@ def __init__( """ self.root = root self.router = router or APIRouter(tags=[tag]) - self.payment_callback: ( + 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,11 +65,16 @@ 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[ + checkout_callback: Callable[ [schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None], ] | None = None, + mypayment_callback: Callable[ + [UUID, AsyncSession], + Awaitable[None], + ] + | None = None, registred_topics: list[Topic] | None = None, permissions: type[ModulePermissions] | 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: ( + 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/migrations/versions/66-mypayment-extended.py b/migrations/versions/66-mypayment-extended.py new file mode 100644 index 0000000000..9cd1ef135f --- /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 = "562adbd796ae" +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/config.test.yaml b/tests/config.test.yaml index ac1281abe5..2ed894cdd2 100644 --- a/tests/config.test.yaml +++ b/tests/config.test.yaml @@ -200,4 +200,4 @@ TRUSTED_PAYMENT_REDIRECT_URLS: # MyECLPay requires an external service to recurrently check for transactions and state integrity, this service needs an access to all the data related to the transactions and the users involved # This service will use a special token to access the data # If this token is not set, the service will not be able to access the data and no integrity check will be performed -#MYECLPAY_DATA_VERIFIER_ACCESS_TOKEN: "" +MYPAYMENT_DATA_VERIFIER_ACCESS_TOKEN: "test_data_verifier_access_token" diff --git a/tests/core/test_checkout.py b/tests/core/test_checkout.py index 8fffefffa8..58fe2f20fc 100644 --- a/tests/core/test_checkout.py +++ b/tests/core/test_checkout.py @@ -304,7 +304,7 @@ 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, ) @@ -347,7 +347,7 @@ 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, ) diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 9820d8a611..5f2b96aa97 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -10,24 +10,37 @@ ) from fastapi.testclient import TestClient from pytest_mock import MockerFixture +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout import schemas_checkout from app.core.groups import models_groups -from app.core.groups.groups_type import GroupType +from app.core.groups.groups_type import AccountType, GroupType from app.core.memberships import models_memberships from app.core.mypayment import cruds_mypayment, models_mypayment from app.core.mypayment.coredata_mypayment import ( MyPaymentBankAccountHolder, ) -from app.core.mypayment.schemas_mypayment import QRCodeContentData +from app.core.mypayment.endpoints_mypayment import MyPaymentPermissions +from app.core.mypayment.schemas_mypayment import ( + SecuredContentData, + SignedContent, +) from app.core.mypayment.types_mypayment import ( + RequestStatus, TransactionStatus, TransactionType, TransferType, WalletDeviceStatus, WalletType, ) -from app.core.mypayment.utils_mypayment import LATEST_TOS +from app.core.mypayment.utils_mypayment import ( + LATEST_TOS, + REQUEST_EXPIRATION, + validate_transfer_callback, +) +from app.core.permissions import models_permissions from app.core.users import models_users +from app.types.module import Module from tests.commons import ( add_coredata_to_db, add_object_to_db, @@ -37,6 +50,8 @@ get_TestingSessionLocal, ) +TEST_MODULE_ROOT = "tests" + bde_group: models_groups.CoreGroup admin_user: models_users.CoreUser @@ -72,6 +87,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 @@ -88,6 +104,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 @@ -103,6 +123,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_payment.value, + account_type=account_type, + ), + ) + global bde_group bde_group = await create_groups_with_permissions( [], @@ -352,6 +380,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( @@ -430,6 +473,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) @@ -475,7 +520,7 @@ async def init_objects() -> None: store_id=store.id, can_bank=True, can_see_history=False, - can_cancel=False, + can_cancel=True, can_manage_sellers=False, ) await add_object_to_db(store_seller_can_bank) @@ -599,6 +644,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( @@ -2389,7 +2478,7 @@ def test_store_scan_store_invalid_signature(client: TestClient): def test_store_scan_store_with_non_store_qr_code(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=-1, iat=datetime.now(UTC), @@ -2424,7 +2513,7 @@ def test_store_scan_store_with_non_store_qr_code(client: TestClient): def test_store_scan_store_negative_total(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=-1, iat=datetime.now(UTC), @@ -2466,7 +2555,7 @@ def test_store_scan_store_missing_wallet( qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=100, iat=datetime.now(UTC), @@ -2502,7 +2591,7 @@ def test_store_scan_store_missing_wallet( def test_store_scan_store_from_store_wallet(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=1100, iat=datetime.now(UTC), @@ -2572,7 +2661,7 @@ async def test_store_scan_store_from_wallet_with_old_tos_version(client: TestCli qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=1100, iat=datetime.now(UTC), @@ -2605,7 +2694,7 @@ async def test_store_scan_store_from_wallet_with_old_tos_version(client: TestCli def test_store_scan_store_insufficient_ballance(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=3000, iat=datetime.now(UTC), @@ -2638,7 +2727,7 @@ def test_store_scan_store_insufficient_ballance(client: TestClient): async def test_store_scan_store_successful_scan(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=500, iat=datetime.now(UTC), @@ -2946,6 +3035,37 @@ async def test_transaction_refund_partial(client: TestClient): ) +async def test_cancel_transaction( + client: TestClient, +): + recent_transaction = models_mypayment.Transaction( + id=uuid4(), + debited_wallet_id=ecl_user_wallet.id, + credited_wallet_id=store_wallet.id, + total=100, + status=TransactionStatus.CONFIRMED, + creation=datetime.now(UTC), + transaction_type=TransactionType.DIRECT, + seller_user_id=store_seller_can_bank_user.id, + debited_wallet_device_id=ecl_user_wallet_device.id, + store_note="", + qr_code_id=None, + ) + await add_object_to_db(recent_transaction) + response = client.post( + f"/mypayment/transactions/{recent_transaction.id}/cancel", + headers={"Authorization": f"Bearer {store_seller_can_bank_user_access_token}"}, + ) + assert response.status_code == 204, response.text + async with get_TestingSessionLocal()() as db: + transaction_after_cancel = await cruds_mypayment.get_transaction( + db=db, + transaction_id=recent_transaction.id, + ) + assert transaction_after_cancel is not None + assert transaction_after_cancel.status == TransactionStatus.CANCELED + + async def test_get_invoices_as_random_user(client: TestClient): response = client.get( "/mypayment/invoices", @@ -3211,3 +3331,503 @@ async def test_delete_invoice( ) assert response.status_code == 200 assert not any(invoice["id"] == invoice3.id for invoice in response.json()) + + +async def mypayment_callback( + object_id: UUID, + db: AsyncSession, +) -> None: + pass + + +async def test_get_request( + client: TestClient, +): + response = client.get( + "/mypayment/requests", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["id"] == str(proposed_request.id) + + +async def test_get_request_with_used_filter( + client: TestClient, +): + response = client.get( + "/mypayment/requests?used=true", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) == 3 + + +async def test_accept_request_with_invalid_signature( + client: TestClient, +): + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=SignedContent( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + signature="invalid signature", + ).model_dump(mode="json"), + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid signature" + + +async def test_accept_request_with_wrong_wallet_device( + client: TestClient, +): + wrong_wallet_device_private_key = Ed25519PrivateKey.generate() + wrong_wallet_device = models_mypayment.WalletDevice( + id=uuid4(), + name="Wrong device", + wallet_id=ecl_user2_wallet.id, + ed25519_public_key=wrong_wallet_device_private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ), + creation=datetime.now(UTC), + status=WalletDeviceStatus.ACTIVE, + activation_token=str(uuid4()), + ) + await add_object_to_db(wrong_wallet_device) + + validation_data = SecuredContentData( + id=proposed_request.id, + key=wrong_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = wrong_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Wallet device is not associated with the user wallet" + ) + + +async def test_accept_request_with_wrong_user( + client: TestClient, +): + validation_data = SecuredContentData( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user2_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not allowed to confirm this request" + + +async def test_accept_request_with_different_total( + client: TestClient, +): + validation_data = SecuredContentData( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total + 100, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Request total in the body do not match the request total in the database" + ) + + +async def test_accept_request_with_inexistant_wallet_device( + client: TestClient, +): + validation_data = SecuredContentData( + id=proposed_request.id, + key=uuid4(), + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Wallet device does not exist" + + +async def test_accept_request_with_wallet_device_linked_to_another_wallet( + client: TestClient, +): + other_wallet = models_mypayment.Wallet( + id=uuid4(), + type=WalletType.USER, + balance=1000, + ) + await add_object_to_db(other_wallet) + + other_wallet_device_private_key = Ed25519PrivateKey.generate() + other_wallet_device = models_mypayment.WalletDevice( + id=uuid4(), + name="Other device", + wallet_id=other_wallet.id, + ed25519_public_key=other_wallet_device_private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ), + creation=datetime.now(UTC), + status=WalletDeviceStatus.ACTIVE, + activation_token=str(uuid4()), + ) + await add_object_to_db(other_wallet_device) + + validation_data = SecuredContentData( + id=proposed_request.id, + key=other_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = other_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Wallet device is not associated with the user wallet" + ) + + +async def test_accept_request_with_non_proposed_request( + client: TestClient, +): + non_proposed_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.ACCEPTED, + creation=datetime.now(UTC), + ) + await add_object_to_db(non_proposed_request) + + validation_data = SecuredContentData( + id=non_proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=non_proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{non_proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Only pending requests can be confirmed" + + +async def test_accept_request( + mocker: MockerFixture, + client: TestClient, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + validation_data = SecuredContentData( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 204 + + responser = client.get( + "/mypayment/requests?used=true", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert responser.status_code == 200 + accepted = next( + ( + request + for request in responser.json() + if request["id"] == str(proposed_request.id) + ), + None, + ) + assert accepted is not None + assert accepted["status"] == RequestStatus.ACCEPTED + mocked_callback.assert_called_once() + + +async def test_accept_expired_request( + client: TestClient, + mocker: MockerFixture, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + expired_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.PROPOSED, + creation=datetime.now(UTC) - timedelta(minutes=REQUEST_EXPIRATION + 1), + ) + await add_object_to_db(expired_request) + + validation_data = SecuredContentData( + id=expired_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=expired_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{expired_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Only pending requests can be confirmed" + + mocked_callback.assert_not_called() + + +async def test_refuse_request( + client: TestClient, + mocker: MockerFixture, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + new_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.PROPOSED, + creation=datetime.now(UTC), + ) + await add_object_to_db(new_request) + + response = client.post( + f"/mypayment/requests/{new_request.id}/refuse", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 204 + + mocked_callback.assert_not_called() + + responser = client.get( + "/mypayment/requests?used=true", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert responser.status_code == 200 + refused = next( + ( + request + for request in responser.json() + if request["id"] == str(new_request.id) + ), + None, + ) + assert refused is not None + assert refused["status"] == RequestStatus.REFUSED + + +async def test_direct_transfer_callback( + mocker: MockerFixture, + client: TestClient, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + async with get_TestingSessionLocal()() as db: + await validate_transfer_callback( + checkout_payment=schemas_checkout.CheckoutPayment( + id=uuid4(), + paid_amount=1500, + checkout_id=UUID(store_direct_transfer.transfer_identifier), + ), + db=db, + ) + + mocked_callback.assert_called_once() + + +async def test_integrity_check( + client: TestClient, +): + response = client.get( + "/mypayment/integrity-check", + headers={"x-data-verifier-token": "test_data_verifier_access_token"}, + ) + assert response.status_code == 200, response.text