From 62efbb33dde8c4deabc5c83eaf359ba8d3caae5b Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sat, 1 Nov 2025 03:13:31 +0100 Subject: [PATCH 1/9] feat: add invoices system --- app/core/myeclpay/coredata_myeclpay.py | 9 + app/core/myeclpay/cruds_myeclpay.py | 278 ++++++++-- app/core/myeclpay/dependencies_myeclpay.py | 34 ++ app/core/myeclpay/endpoints_myeclpay.py | 563 +++++++++++++++++++-- app/core/myeclpay/exceptions_myeclpay.py | 20 + app/core/myeclpay/integrity_myeclpay.py | 7 + app/core/myeclpay/models_myeclpay.py | 58 +++ app/core/myeclpay/schemas_myeclpay.py | 86 +++- app/core/myeclpay/types_myeclpay.py | 1 + app/core/myeclpay/utils_myeclpay.py | 122 ++++- app/dependencies.py | 2 +- app/types/websocket.py | 2 +- app/utils/auth/auth_utils.py | 2 +- tests/test_myeclpay.py | 459 ++++++++++++++++- 14 files changed, 1548 insertions(+), 95 deletions(-) create mode 100644 app/core/myeclpay/coredata_myeclpay.py create mode 100644 app/core/myeclpay/dependencies_myeclpay.py diff --git a/app/core/myeclpay/coredata_myeclpay.py b/app/core/myeclpay/coredata_myeclpay.py new file mode 100644 index 0000000000..6b0944c31c --- /dev/null +++ b/app/core/myeclpay/coredata_myeclpay.py @@ -0,0 +1,9 @@ +from uuid import UUID + +from app.types.core_data import BaseCoreData + + +class MyECLPayBankAccountHolder(BaseCoreData): + """Bank account holder information for MyECLPay.""" + + holder_structure_id: UUID diff --git a/app/core/myeclpay/cruds_myeclpay.py b/app/core/myeclpay/cruds_myeclpay.py index bd07322f9e..10d399de34 100644 --- a/app/core/myeclpay/cruds_myeclpay.py +++ b/app/core/myeclpay/cruds_myeclpay.py @@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import noload, selectinload -from app.core.memberships import schemas_memberships from app.core.myeclpay import models_myeclpay, schemas_myeclpay from app.core.myeclpay.exceptions_myeclpay import WalletNotFoundOnUpdateError from app.core.myeclpay.types_myeclpay import ( @@ -14,19 +13,32 @@ WalletDeviceStatus, WalletType, ) +from app.core.myeclpay.utils_myeclpay import ( + invoice_model_to_schema, + structure_model_to_schema, +) from app.core.users import schemas_users async def create_structure( - structure: schemas_myeclpay.Structure, + structure: schemas_myeclpay.StructureSimple, db: AsyncSession, ) -> None: db.add( models_myeclpay.Structure( id=structure.id, + short_id=structure.short_id, name=structure.name, association_membership_id=structure.association_membership_id, manager_user_id=structure.manager_user_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, ), ) @@ -112,28 +124,7 @@ async def get_structures( ) -> Sequence[schemas_myeclpay.Structure]: result = await db.execute(select(models_myeclpay.Structure)) return [ - schemas_myeclpay.Structure( - 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, - id=structure.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, - ), - ) - for structure in result.scalars().all() + structure_model_to_schema(structure) for structure in result.scalars().all() ] @@ -152,31 +143,7 @@ async def get_structure_by_id( .scalars() .first() ) - return ( - schemas_myeclpay.Structure( - 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, - id=structure.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, - ), - ) - if structure - else None - ) + return structure_model_to_schema(structure) if structure else None async def create_store( @@ -981,3 +948,216 @@ async def delete_used_qrcode( models_myeclpay.UsedQRCode.qr_code_id == qr_code_id, ), ) + + +async def get_invoices( + db: AsyncSession, + skip: int | None = None, + limit: int | None = None, + structures_ids: list[UUID] | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, +) -> list[schemas_myeclpay.Invoice]: + select_command = ( + select(models_myeclpay.Invoice) + .where( + models_myeclpay.Invoice.end_date >= start_date + if start_date + else and_(True), + models_myeclpay.Invoice.end_date <= end_date if end_date else and_(True), + models_myeclpay.Invoice.structure_id.in_(structures_ids) + if structures_ids + else and_(True), + ) + .order_by( + models_myeclpay.Invoice.end_date.desc(), + ) + ) + if skip is not None: + select_command = select_command.offset(skip) + if limit is not None: + select_command = select_command.limit(limit) + result = await db.execute(select_command) + return [invoice_model_to_schema(invoice) for invoice in result.scalars().all()] + + +async def get_invoice_by_id( + invoice_id: UUID, + db: AsyncSession, +) -> schemas_myeclpay.Invoice | None: + result = await db.execute( + select(models_myeclpay.Invoice) + .where( + models_myeclpay.Invoice.id == invoice_id, + ) + .with_for_update(of=models_myeclpay.Invoice), + ) + invoice = result.scalars().first() + return invoice_model_to_schema(invoice) if invoice else None + + +async def get_pending_invoices_by_structure_id( + structure_id: UUID, + db: AsyncSession, +) -> list[schemas_myeclpay.Invoice]: + result = await db.execute( + select(models_myeclpay.Invoice).where( + models_myeclpay.Invoice.structure_id == structure_id, + models_myeclpay.Invoice.received.is_(False), + ), + ) + return [invoice_model_to_schema(invoice) for invoice in result.scalars().all()] + + +async def get_unreceived_invoices_by_store_id( + store_id: UUID, + db: AsyncSession, +) -> list[schemas_myeclpay.InvoiceDetailBase]: + result = await db.execute( + select(models_myeclpay.InvoiceDetail) + .join(models_myeclpay.Invoice) + .where( + models_myeclpay.InvoiceDetail.store_id == store_id, + models_myeclpay.Invoice.received.is_(False), + ), + ) + return [ + schemas_myeclpay.InvoiceDetailBase( + invoice_id=detail.invoice_id, + store_id=detail.store_id, + total=detail.total, + ) + for detail in result.scalars().all() + ] + + +async def create_invoice( + invoice: schemas_myeclpay.InvoiceInfo, + db: AsyncSession, +) -> None: + invoice_db = models_myeclpay.Invoice( + id=invoice.id, + reference=invoice.reference, + creation=invoice.creation, + start_date=invoice.start_date, + end_date=invoice.end_date, + total=invoice.total, + structure_id=invoice.structure_id, + received=invoice.received, + ) + db.add(invoice_db) + for detail in invoice.details: + detail_db = models_myeclpay.InvoiceDetail( + invoice_id=invoice.id, + store_id=detail.store_id, + total=detail.total, + ) + db.add(detail_db) + + +async def update_invoice_received_status( + invoice_id: UUID, + db: AsyncSession, +) -> None: + await db.execute( + update(models_myeclpay.Invoice) + .where(models_myeclpay.Invoice.id == invoice_id) + .values(received=True), + ) + + +async def update_invoice_paid_status( + invoice_id: UUID, + paid: bool, + db: AsyncSession, +) -> None: + await db.execute( + update(models_myeclpay.Invoice) + .where(models_myeclpay.Invoice.id == invoice_id) + .values(paid=paid), + ) + + +async def delete_invoice( + invoice_id: UUID, + db: AsyncSession, +) -> None: + await db.execute( + delete(models_myeclpay.InvoiceDetail).where( + models_myeclpay.InvoiceDetail.invoice_id == invoice_id, + ), + ) + await db.execute( + delete(models_myeclpay.Invoice).where( + models_myeclpay.Invoice.id == invoice_id, + ), + ) + + +async def get_last_structure_invoice( + structure_id: UUID, + db: AsyncSession, +) -> schemas_myeclpay.Invoice | None: + result = ( + ( + await db.execute( + select(models_myeclpay.Invoice) + .where( + models_myeclpay.Invoice.structure_id == structure_id, + ) + .order_by(models_myeclpay.Invoice.end_date.desc()) + .limit(1), + ) + ) + .scalars() + .first() + ) + return invoice_model_to_schema(result) if result else None + + +async def add_withdrawal( + withdrawal: schemas_myeclpay.Withdrawal, + db: AsyncSession, +) -> None: + withdrawal_db = models_myeclpay.Withdrawal( + id=withdrawal.id, + wallet_id=withdrawal.wallet_id, + total=withdrawal.total, + creation=withdrawal.creation, + ) + db.add(withdrawal_db) + + +async def get_withdrawals( + db: AsyncSession, +) -> list[schemas_myeclpay.Withdrawal]: + result = await db.execute(select(models_myeclpay.Withdrawal)) + return [ + schemas_myeclpay.Withdrawal( + id=withdrawal.id, + wallet_id=withdrawal.wallet_id, + total=withdrawal.total, + creation=withdrawal.creation, + ) + for withdrawal in result.scalars().all() + ] + + +async def get_withdrawals_by_wallet_id( + wallet_id: UUID, + db: AsyncSession, +) -> list[schemas_myeclpay.Withdrawal]: + result = await db.execute( + select(models_myeclpay.Withdrawal).where( + models_myeclpay.Withdrawal.wallet_id == wallet_id, + ), + ) + return [ + schemas_myeclpay.Withdrawal( + id=withdrawal.id, + wallet_id=withdrawal.wallet_id, + total=withdrawal.total, + creation=withdrawal.creation, + ) + for withdrawal in result.scalars().all() + ] diff --git a/app/core/myeclpay/dependencies_myeclpay.py b/app/core/myeclpay/dependencies_myeclpay.py new file mode 100644 index 0000000000..5d47a68e3f --- /dev/null +++ b/app/core/myeclpay/dependencies_myeclpay.py @@ -0,0 +1,34 @@ +from fastapi import Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.myeclpay.coredata_myeclpay import MyECLPayBankAccountHolder +from app.core.myeclpay.cruds_myeclpay import get_structure_by_id +from app.core.users.models_users import CoreUser +from app.dependencies import get_db, is_user +from app.utils.tools import get_core_data + + +async def is_user_bank_account_holder( + user: CoreUser = Depends(is_user()), + db: AsyncSession = Depends(get_db), +) -> CoreUser: + """Check if the user is a bank account holder.""" + account_holder = await get_core_data( + MyECLPayBankAccountHolder, + db=db, + ) + structure = await get_structure_by_id( + db=db, + structure_id=account_holder.holder_structure_id, + ) + if not structure: + raise HTTPException( + status_code=404, + detail="Structure not found for the bank account holder", + ) + if structure.manager_user_id != user.id: + raise HTTPException( + status_code=403, + detail="User is not the bank account holder", + ) + return user diff --git a/app/core/myeclpay/endpoints_myeclpay.py b/app/core/myeclpay/endpoints_myeclpay.py index 30f6e30749..808db0e6c5 100644 --- a/app/core/myeclpay/endpoints_myeclpay.py +++ b/app/core/myeclpay/endpoints_myeclpay.py @@ -15,21 +15,28 @@ HTTPException, Query, ) -from fastapi.responses import RedirectResponse +from fastapi.responses import FileResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy.ext.asyncio import AsyncSession +from app.core.auth import schemas_auth from app.core.core_endpoints import cruds_core from app.core.groups.groups_type import GroupType -from app.core.memberships import schemas_memberships from app.core.memberships.utils_memberships import ( get_user_active_membership_to_association_membership, ) from app.core.myeclpay import cruds_myeclpay, schemas_myeclpay +from app.core.myeclpay.coredata_myeclpay import MyECLPayBankAccountHolder +from app.core.myeclpay.dependencies_myeclpay import is_user_bank_account_holder +from app.core.myeclpay.exceptions_myeclpay import ( + InvoiceNotFoundAfterCreationError, + ReferencedStructureNotFoundError, +) from app.core.myeclpay.integrity_myeclpay import ( format_cancel_log, format_refund_log, format_transaction_log, + format_withdrawal_log, ) from app.core.myeclpay.models_myeclpay import Store, WalletDevice from app.core.myeclpay.types_myeclpay import ( @@ -45,6 +52,7 @@ LATEST_TOS, QRCODE_EXPIRATION, is_user_latest_tos_signed, + structure_model_to_schema, validate_transfer_callback, verify_signature, ) @@ -69,8 +77,16 @@ ) from app.types import standard_responses from app.types.module import CoreModule +from app.types.scopes_type import ScopeType +from app.utils.auth.auth_utils import get_token_data, get_user_id_from_token_with_scopes from app.utils.communication.notifications import NotificationTool from app.utils.mail.mailworker import send_email +from app.utils.tools import ( + generate_pdf_from_template, + get_core_data, + get_file_from_data, + set_core_data, +) router = APIRouter(tags=["MyECLPay"]) @@ -97,6 +113,63 @@ RETENTION_DURATION = 10 * 365 # 10 years in days +@router.get( + "/myeclpay/bank-account-holder", + response_model=schemas_myeclpay.Structure, + status_code=200, +) +async def get_bank_account_holder( + user: CoreUser = Depends(is_user()), + db: AsyncSession = Depends(get_db), +): + """ + Get the current bank account holder information. + """ + bank_account_holder = await get_core_data( + MyECLPayBankAccountHolder, + db=db, + ) + structure = await cruds_myeclpay.get_structure_by_id( + db=db, + structure_id=bank_account_holder.holder_structure_id, + ) + if structure is None: + raise ReferencedStructureNotFoundError( + structure_id=bank_account_holder.holder_structure_id, + ) + return structure + + +@router.post( + "/myeclpay/bank-account-holder", + response_model=schemas_myeclpay.Structure, + status_code=201, +) +async def set_bank_account_holder( + bank_account_info: MyECLPayBankAccountHolder, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_in(GroupType.admin)), +): + """Set the bank account holder information.""" + + structure = await cruds_myeclpay.get_structure_by_id( + structure_id=bank_account_info.holder_structure_id, + db=db, + ) + if structure is None: + raise HTTPException( + status_code=404, + detail="Structure does not exist", + ) + + await set_core_data( + bank_account_info, + db=db, + ) + + return structure + + @router.get( "/myeclpay/structures", status_code=200, @@ -144,20 +217,20 @@ async def create_structure( status_code=404, detail="Manager user does not exist", ) - structure_db = schemas_myeclpay.Structure( + structure_db = schemas_myeclpay.StructureSimple( id=uuid.uuid4(), + short_id=structure.short_id, name=structure.name, association_membership_id=structure.association_membership_id, association_membership=None, manager_user_id=structure.manager_user_id, - manager_user=schemas_users.CoreUserSimple( - id=db_user.id, - name=db_user.name, - firstname=db_user.firstname, - nickname=db_user.nickname, - account_type=db_user.account_type, - school_id=db_user.school_id, - ), + siege_address_street=structure.siege_address_street, + siege_address_zipcode=structure.siege_address_zipcode, + siege_address_city=structure.siege_address_city, + siege_address_country=structure.siege_address_country, + iban=structure.iban, + bic=structure.bic, + creation=datetime.now(tz=UTC), ) await cruds_myeclpay.create_structure( structure=structure_db, @@ -462,6 +535,7 @@ async def create_store( name=store.name, structure_id=structure_id, wallet_id=wallet_id, + creation=datetime.now(tz=UTC), ) await cruds_myeclpay.create_store( store=store_db, @@ -493,6 +567,7 @@ async def create_store( name=store_db.name, structure_id=store_db.structure_id, wallet_id=store_db.wallet_id, + creation=store_db.creation, structure=structure, ) @@ -651,28 +726,9 @@ async def get_user_stores( schemas_myeclpay.UserStore( id=store.id, name=store.name, + creation=store.creation, structure_id=store.structure_id, - structure=schemas_myeclpay.Structure( - id=store.structure.id, - name=store.structure.name, - association_membership_id=store.structure.association_membership_id, - association_membership=schemas_memberships.MembershipSimple( - id=store.structure.association_membership.id, - name=store.structure.association_membership.name, - manager_group_id=store.structure.association_membership.manager_group_id, - ) - if store.structure.association_membership is not None - else None, - manager_user_id=store.structure.manager_user_id, - manager_user=schemas_users.CoreUserSimple( - id=store.structure.manager_user.id, - name=store.structure.manager_user.name, - firstname=store.structure.manager_user.firstname, - nickname=store.structure.manager_user.nickname, - account_type=store.structure.manager_user.account_type, - school_id=store.structure.manager_user.school_id, - ), - ), + structure=structure_model_to_schema(store.structure), wallet_id=store.wallet_id, can_bank=seller.can_bank, can_see_history=seller.can_see_history, @@ -2466,6 +2522,449 @@ async def cancel_transaction( ) +@router.get( + "/myeclpay/invoices", + response_model=list[schemas_myeclpay.Invoice], +) +async def get_invoices( + page: int | None = None, + page_size: int | None = None, + structures_ids: list[UUID] | None = Query(default=None), + start_date: datetime | None = None, + end_date: datetime | None = None, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_bank_account_holder), +) -> list[schemas_myeclpay.Invoice]: + """ + Get all invoices. + + **The user must be authenticated to use this endpoint** + """ + return await cruds_myeclpay.get_invoices( + db=db, + skip=(page - 1) * page_size if page and page_size else None, + limit=page_size, + start_date=start_date, + end_date=end_date, + structures_ids=structures_ids, + ) + + +@router.get( + "/myeclpay/invoices/structures/{structure_id}", + response_model=list[schemas_myeclpay.Invoice], +) +async def get_structure_invoices( + structure_id: UUID, + page: int | None = None, + page_size: int | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user()), +) -> list[schemas_myeclpay.Invoice]: + """ + Get all invoices. + + **The user must be the structure manager** + """ + structure = await cruds_myeclpay.get_structure_by_id( + structure_id=structure_id, + db=db, + ) + if structure is None: + raise HTTPException( + status_code=404, + detail="Structure does not exist", + ) + if structure.manager_user_id != user.id: + raise HTTPException( + status_code=403, + detail="User is not allowed to access this structure invoices", + ) + + return await cruds_myeclpay.get_invoices( + db=db, + skip=(page - 1) * page_size if page and page_size else None, + limit=page_size, + start_date=start_date, + end_date=end_date, + structures_ids=[structure_id], + ) + + +@router.get( + "/myeclpay/invoices/{invoice_id}", + response_class=FileResponse, +) +async def download_invoice( + invoice_id: UUID, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user()), +): + invoice = await cruds_myeclpay.get_invoice_by_id(invoice_id, db) + if invoice is None: + raise HTTPException( + status_code=404, + detail="Invoice does not exist", + ) + structure = await cruds_myeclpay.get_structure_by_id( + structure_id=invoice.structure_id, + db=db, + ) + if structure is None: + raise HTTPException( + status_code=404, + detail="Structure does not exist", + ) + bank_account_info = await get_bank_account_holder( + user=user, + db=db, + ) + if user.id not in ( + structure.manager_user_id, + bank_account_info.manager_user_id, + ): + raise HTTPException( + status_code=403, + detail="User is not allowed to access this invoice", + ) + return get_file_from_data( + directory="myeclpay/invoices", + filename=str(invoice_id), + ) + + +@router.post( + "/myeclpay/invoices/structures/{structure_id}", + response_model=schemas_myeclpay.Invoice, + status_code=201, +) +async def create_structure_invoice( + structure_id: UUID, + db: AsyncSession = Depends(get_db), + settings: Settings = Depends(get_settings), + token_data: schemas_auth.TokenData = Depends(get_token_data), +): + """ + Create an invoice for a structure. + + **The user must be the bank account holder** + """ + now = await cruds_core.start_isolation_mode(db) + # Database isolation requires to be the first statement of the transaction + # We can't use reguler dependencies to check user permissions as they access the database + user_id = get_user_id_from_token_with_scopes( + scopes=[[ScopeType.API]], + token_data=token_data, + ) + user = await cruds_users.get_user_by_id( + db=db, + user_id=user_id, + ) + if user is None: + raise HTTPException( + status_code=404, + detail="User does not exist", + ) + bank_holder_structure = await get_bank_account_holder( + user=user, + db=db, + ) + if bank_holder_structure.manager_user_id != user.id: + raise HTTPException( + status_code=403, + detail="User is not the bank account holder", + ) + structure = await cruds_myeclpay.get_structure_by_id( + structure_id=structure_id, + db=db, + ) + if structure is None: + raise HTTPException( + status_code=404, + detail="Structure does not exist", + ) + + stores = await cruds_myeclpay.get_stores_by_structure_id( + structure_id=structure_id, + db=db, + ) + invoice_details: list[schemas_myeclpay.InvoiceDetailBase] = [] + invoice_id = uuid.uuid4() + + # We use a 30 seconds delay to avoid unstable transactions + # as they can be canceled during the 30 seconds after their creation + security_now = now - timedelta(seconds=30) + to_substract_transactions = await cruds_myeclpay.get_transactions( + db=db, + start_date=security_now, + exclude_canceled=True, + ) + + for store in stores: + store_wallet_db = await cruds_myeclpay.get_wallet( + wallet_id=store.wallet_id, + db=db, + ) + if store_wallet_db is None: + hyperion_error_logger.error( + "MyPayment: Could not find wallet associated with a store, this should never happen", + ) + raise HTTPException( + status_code=500, + detail="Could not find wallet associated with the store", + ) + store_wallet = schemas_myeclpay.Wallet( + id=store_wallet_db.id, + type=store_wallet_db.type, + balance=store_wallet_db.balance, + user=None, + store=None, + ) + for transaction in to_substract_transactions: + if transaction.credited_wallet_id == store_wallet.id: + store_wallet.balance -= transaction.total + elif transaction.debited_wallet_id == store_wallet.id: + store_wallet.balance += transaction.total + store_pending_invoices = ( + await cruds_myeclpay.get_unreceived_invoices_by_store_id( + store_id=store.id, + db=db, + ) + ) + for pending_invoice in store_pending_invoices: + store_wallet.balance -= pending_invoice.total + if store_wallet.balance != 0: + invoice_details.append( + schemas_myeclpay.InvoiceDetailBase( + invoice_id=invoice_id, + store_id=store.id, + store_name=store.name, + wallet_id=store_wallet.id, + total=store_wallet.balance, + ), + ) + if not invoice_details: + raise HTTPException( + status_code=400, + detail="No invoice to create", + ) + last_structure_invoice = await cruds_myeclpay.get_last_structure_invoice( + structure_id=structure_id, + db=db, + ) + last_invoice_number = ( + int(last_structure_invoice.reference[-4:]) + if last_structure_invoice + and int(last_structure_invoice.reference[5:9]) == security_now.year + else 0 + ) + invoice = schemas_myeclpay.InvoiceInfo( + id=invoice_id, + reference=f"MYPAY{security_now.year}{structure.short_id}{last_invoice_number + 1:04d}", + structure_id=structure_id, + creation=datetime.now(UTC), + start_date=last_structure_invoice.end_date + if last_structure_invoice + else structure.creation, + end_date=security_now, + total=sum(detail.total for detail in invoice_details), + details=invoice_details, + ) + await cruds_myeclpay.create_invoice( + invoice=invoice, + db=db, + ) + invoice_db = await cruds_myeclpay.get_invoice_by_id( + invoice_id=invoice_id, + db=db, + ) + if invoice_db is None: + raise InvoiceNotFoundAfterCreationError(invoice_id=invoice_id) + + context = { + "invoice": invoice_db.model_dump(), + "payment_name": "MyECLPay", + "holder_coordinates": { + "name": bank_holder_structure.name, + "address_street": bank_holder_structure.siege_address_street, + "address_city": bank_holder_structure.siege_address_city, + "address_zipcode": bank_holder_structure.siege_address_zipcode, + "address_country": bank_holder_structure.siege_address_country, + "siret": bank_holder_structure.siret, + }, + } + await generate_pdf_from_template( + template_name="myeclpay_invoice.html", + directory="myeclpay/invoices", + filename=invoice.id, + context=context, + ) + return invoice_db + + +@router.patch( + "/myeclpay/invoices/{invoice_id}/paid", + status_code=204, +) +async def update_invoice_paid_status( + invoice_id: UUID, + paid: bool, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_bank_account_holder), +): + """ + Update the paid status of a structure invoice. + + **The user must be the bank account holder** + """ + hyperion_error_logger.debug( + f"User {user.id} requested to update the paid status of invoice {invoice_id} to {paid}", + ) + invoice = await cruds_myeclpay.get_invoice_by_id( + invoice_id=invoice_id, + db=db, + ) + if invoice is None: + raise HTTPException( + status_code=404, + detail="Invoice does not exist", + ) + await cruds_myeclpay.update_invoice_paid_status( + invoice_id=invoice.id, + paid=paid, + db=db, + ) + + +@router.patch( + "/myeclpay/invoices/{invoice_id}/received", + status_code=204, +) +async def aknowledge_invoice_as_received( + invoice_id: UUID, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user()), +): + """ + Update the received status of a structure invoice. + + **The user must be the structure manager** + """ + invoice = await cruds_myeclpay.get_invoice_by_id( + invoice_id=invoice_id, + db=db, + ) + if invoice is None: + raise HTTPException( + status_code=404, + detail="Invoice does not exist", + ) + if not invoice.paid: + raise HTTPException( + status_code=400, + detail="Cannot mark an invoice as received if it is not paid", + ) + if invoice.received: + raise HTTPException( + status_code=400, + detail="Invoice is already marked as received", + ) + structure = await cruds_myeclpay.get_structure_by_id( + structure_id=invoice.structure_id, + db=db, + ) + if structure is None: + raise HTTPException( + status_code=500, + detail="Structure does not exist", + ) + if structure.manager_user_id != user.id: + raise HTTPException( + status_code=403, + detail="User is not allowed to edit this structure invoice", + ) + + await cruds_myeclpay.update_invoice_received_status( + invoice_id=invoice.id, + db=db, + ) + for detail in invoice.details: + store = await cruds_myeclpay.get_store( + store_id=detail.store_id, + db=db, + ) + if store is None: + hyperion_error_logger.error( + "MyPayment: Could not find store associated with an invoice, this should never happen", + ) + raise HTTPException( + status_code=500, + detail="Could not find store associated with the invoice", + ) + await cruds_myeclpay.increment_wallet_balance( + wallet_id=store.wallet_id, + amount=-detail.total, + db=db, + ) + await cruds_myeclpay.add_withdrawal( + schemas_myeclpay.Withdrawal( + id=uuid.uuid4(), + wallet_id=store.wallet_id, + total=detail.total, + creation=datetime.now(UTC), + ), + db=db, + ) + + hyperion_myeclpay_logger.info( + format_withdrawal_log( + wallet_id=store.wallet_id, + total=detail.total, + ), + extra={ + "s3_subfolder": MYECLPAY_LOGS_S3_SUBFOLDER, + "s3_retention": RETENTION_DURATION, + }, + ) + + +@router.delete( + "/myeclpay/invoices/{invoice_id}", + status_code=204, +) +async def delete_structure_invoice( + invoice_id: UUID, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_bank_account_holder), +): + """ + Delete a structure invoice. + + **The user must be the bank account holder** + """ + invoice = await cruds_myeclpay.get_invoice_by_id( + invoice_id=invoice_id, + db=db, + ) + if invoice is None: + raise HTTPException( + status_code=404, + detail="Invoice does not exist", + ) + if invoice.paid: + raise HTTPException( + status_code=400, + detail="Cannot delete an invoice that has already been paid", + ) + + await cruds_myeclpay.delete_invoice( + invoice_id=invoice.id, + db=db, + ) + + @router.get( "/myeclpay/integrity-check", status_code=200, diff --git a/app/core/myeclpay/exceptions_myeclpay.py b/app/core/myeclpay/exceptions_myeclpay.py index 95f720147f..1d639cc02d 100644 --- a/app/core/myeclpay/exceptions_myeclpay.py +++ b/app/core/myeclpay/exceptions_myeclpay.py @@ -9,3 +9,23 @@ class WalletNotFoundOnUpdateError(Exception): def __init__(self, wallet_id: UUID): super().__init__(f"Wallet {wallet_id} not found when updating") + + +class InvoiceNotFoundAfterCreationError(Exception): + """ + Exception raised when an invoice is not found after its creation. + This should lead to an internal server error response and a rollback of the transaction. + """ + + def __init__(self, invoice_id: UUID): + super().__init__(f"Invoice {invoice_id} not found after creation") + + +class ReferencedStructureNotFoundError(Exception): + """ + Exception raised when a referenced structure is not found. + This should lead to an internal server error response and a rollback of the transaction. + """ + + def __init__(self, structure_id: UUID): + super().__init__(f"Referenced structure {structure_id} not found") diff --git a/app/core/myeclpay/integrity_myeclpay.py b/app/core/myeclpay/integrity_myeclpay.py index 1a129f41b7..a296dc3bea 100644 --- a/app/core/myeclpay/integrity_myeclpay.py +++ b/app/core/myeclpay/integrity_myeclpay.py @@ -43,3 +43,10 @@ def format_cancel_log( transaction_id: UUID, ): return f"{ActionType.CANCEL.name} {transaction_id}" + + +def format_withdrawal_log( + wallet_id: UUID, + total: int, +): + return f"{ActionType.WITHDRAWAL.name} {wallet_id} {total}" diff --git a/app/core/myeclpay/models_myeclpay.py b/app/core/myeclpay/models_myeclpay.py index 9bad1b30ba..77dfb29a6b 100644 --- a/app/core/myeclpay/models_myeclpay.py +++ b/app/core/myeclpay/models_myeclpay.py @@ -120,8 +120,17 @@ class Structure(Base): __tablename__ = "myeclpay_structure" id: Mapped[PrimaryKey] + short_id: Mapped[str] = mapped_column(unique=True) name: Mapped[str] = mapped_column(unique=True) + siege_address_street: Mapped[str] + siege_address_city: Mapped[str] + siege_address_zipcode: Mapped[str] + siege_address_country: Mapped[str] + siret: Mapped[str | None] + iban: Mapped[str] + bic: Mapped[str] manager_user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + creation: Mapped[datetime] association_membership_id: Mapped[UUID | None] = mapped_column( ForeignKey("core_association_membership.id"), default=None, @@ -159,6 +168,7 @@ class Store(Base): ForeignKey("myeclpay_wallet.id"), unique=True, ) + creation: Mapped[datetime] structure: Mapped[Structure] = relationship(init=False, lazy="joined") @@ -240,3 +250,51 @@ class UsedQRCode(Base): qr_code_key: Mapped[UUID | None] qr_code_store: Mapped[bool | None] signature: Mapped[str | None] + + +class Invoice(Base): + __tablename__ = "myeclpay_invoice" + + id: Mapped[PrimaryKey] + reference: Mapped[str] = mapped_column(unique=True) + creation: Mapped[datetime] + start_date: Mapped[datetime] + end_date: Mapped[datetime] + total: Mapped[int] # Stored in cents + structure_id: Mapped[UUID] = mapped_column(ForeignKey("myeclpay_structure.id")) + paid: Mapped[bool] = mapped_column(default=False) + received: Mapped[bool] = mapped_column(default=False) + + details: Mapped[list["InvoiceDetail"]] = relationship( + init=False, + lazy="selectin", + ) + structure: Mapped[Structure] = relationship( + init=False, + lazy="joined", + ) + + +class InvoiceDetail(Base): + __tablename__ = "myeclpay_invoice_detail" + + invoice_id: Mapped[UUID] = mapped_column( + ForeignKey("myeclpay_invoice.id"), + primary_key=True, + ) + store_id: Mapped[UUID] = mapped_column( + ForeignKey("myeclpay_store.id"), + primary_key=True, + ) + total: Mapped[int] # Stored in cents + + store: Mapped[Store] = relationship(init=False, lazy="joined") + + +class Withdrawal(Base): + __tablename__ = "myeclpay_withdrawal" + + id: Mapped[PrimaryKey] + wallet_id: Mapped[UUID] = mapped_column(ForeignKey("myeclpay_wallet.id")) + total: Mapped[int] # Stored in cents + creation: Mapped[datetime] diff --git a/app/core/myeclpay/schemas_myeclpay.py b/app/core/myeclpay/schemas_myeclpay.py index c6e87fb494..fcb4ffd37f 100644 --- a/app/core/myeclpay/schemas_myeclpay.py +++ b/app/core/myeclpay/schemas_myeclpay.py @@ -4,6 +4,7 @@ from pydantic import ( BaseModel, Field, + model_validator, ) from app.core.memberships import schemas_memberships @@ -19,13 +20,29 @@ class StructureBase(BaseModel): + short_id: str = Field( + min_length=3, + max_length=3, + description="Short ID of the structure, used for invoices", + ) name: str association_membership_id: UUID | None = None manager_user_id: str + siege_address_street: str + siege_address_city: str + siege_address_zipcode: str + siege_address_country: str + siret: str | None = None + iban: str + bic: str -class Structure(StructureBase): +class StructureSimple(StructureBase): id: UUID + creation: datetime + + +class Structure(StructureSimple): manager_user: schemas_users.CoreUserSimple association_membership: schemas_memberships.MembershipSimple | None @@ -33,6 +50,13 @@ class Structure(StructureBase): class StructureUpdate(BaseModel): name: str | None = None association_membership_id: UUID | None = None + siret: str | None = None + siege_address_street: str | None = None + siege_address_city: str | None = None + siege_address_zipcode: str | None = None + siege_address_country: str | None = None + iban: str | None = None + bic: str | None = None class StructureTranfert(BaseModel): @@ -43,10 +67,14 @@ class StoreBase(BaseModel): name: str -class Store(StoreBase): +class StoreSimple(StoreBase): id: UUID structure_id: UUID wallet_id: UUID + creation: datetime + + +class Store(StoreSimple): structure: Structure @@ -259,3 +287,57 @@ class IntegrityCheckData(BaseModel): transactions: list[TransactionBase] transfers: list[Transfer] refunds: list[RefundBase] + + +class BankAccountHolderEdit(BaseModel): + holder_user_id: str + + +class InvoiceDetailBase(BaseModel): + invoice_id: UUID + store_id: UUID + total: int # Stored in cents + + +class InvoiceDetail(InvoiceDetailBase): + store: StoreSimple + + +class InvoiceBase(BaseModel): + id: UUID + reference: str + structure_id: UUID + creation: datetime + start_date: datetime + end_date: datetime + total: int # Stored in cents + paid: bool = False + received: bool = False + + +class InvoiceInfo(InvoiceBase): + details: list[InvoiceDetailBase] + + @model_validator(mode="after") + def validate_sum(self): + if sum(detail.total for detail in self.details) != self.total: + raise ValueError + return self + + +class Invoice(InvoiceBase): + structure: Structure + details: list[InvoiceDetail] + + @model_validator(mode="after") + def validate_details(self): + if sum(detail.total for detail in self.details) != self.total: + raise ValueError + return self + + +class Withdrawal(BaseModel): + id: UUID + wallet_id: UUID + total: int # Stored in cents + creation: datetime diff --git a/app/core/myeclpay/types_myeclpay.py b/app/core/myeclpay/types_myeclpay.py index d8d89545fe..2fce885bc4 100644 --- a/app/core/myeclpay/types_myeclpay.py +++ b/app/core/myeclpay/types_myeclpay.py @@ -56,6 +56,7 @@ class ActionType(str, Enum): REFUND = "refund" CANCEL = "cancel" TRANSACTION = "transaction" + WITHDRAWAL = "withdrawal" class UnexpectedError(Exception): diff --git a/app/core/myeclpay/utils_myeclpay.py b/app/core/myeclpay/utils_myeclpay.py index 94a49212ba..73464fd555 100644 --- a/app/core/myeclpay/utils_myeclpay.py +++ b/app/core/myeclpay/utils_myeclpay.py @@ -6,7 +6,8 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 from sqlalchemy.ext.asyncio import AsyncSession -from app.core.myeclpay import cruds_myeclpay +from app.core.memberships import schemas_memberships +from app.core.myeclpay import cruds_myeclpay, models_myeclpay, schemas_myeclpay from app.core.myeclpay.integrity_myeclpay import format_transfer_log from app.core.myeclpay.models_myeclpay import UserPayment from app.core.myeclpay.schemas_myeclpay import ( @@ -18,6 +19,7 @@ TransferTotalDontMatchInCallbackError, ) from app.core.payment import schemas_payment +from app.core.users import schemas_users hyperion_security_logger = logging.getLogger("hyperion.security") hyperion_myeclpay_logger = logging.getLogger("hyperion.myeclpay") @@ -116,3 +118,121 @@ async def validate_transfer_callback( "s3_retention": RETENTION_DURATION, }, ) + + +def structure_model_to_schema( + structure: models_myeclpay.Structure, +) -> schemas_myeclpay.Structure: + """ + Convert a structure model to a schema. + """ + return schemas_myeclpay.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_myeclpay.Refund, +) -> schemas_myeclpay.Refund: + """ + Convert a refund model to a schema. + """ + return schemas_myeclpay.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_myeclpay.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_myeclpay.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_myeclpay.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_myeclpay.Invoice, +) -> schemas_myeclpay.Invoice: + """ + Convert an invoice model to a schema. + """ + return schemas_myeclpay.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_myeclpay.InvoiceDetail( + invoice_id=invoice.id, + store_id=detail.store_id, + total=detail.total, + store=schemas_myeclpay.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/dependencies.py b/app/dependencies.py index 07c32266a9..0564433ee9 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -334,7 +334,7 @@ async def get_current_user_id( The expected scopes are passed as list of list of scopes, each list of scopes is an "AND" condition, and the list of list of scopes is an "OR" condition. """ - return await auth_utils.get_user_id_from_token_with_scopes( + return auth_utils.get_user_id_from_token_with_scopes( scopes=scopes, token_data=token_data, ) diff --git a/app/types/websocket.py b/app/types/websocket.py index dd6ba0e750..b51c0d335f 100644 --- a/app/types/websocket.py +++ b/app/types/websocket.py @@ -257,7 +257,7 @@ async def manage_websocket( request_id="websocket", ) - user_id = await auth_utils.get_user_id_from_token_with_scopes( + user_id = auth_utils.get_user_id_from_token_with_scopes( scopes=[[ScopeType.API]], token_data=token_data, ) diff --git a/app/utils/auth/auth_utils.py b/app/utils/auth/auth_utils.py index 2ce76af9bd..3948f70e32 100644 --- a/app/utils/auth/auth_utils.py +++ b/app/utils/auth/auth_utils.py @@ -59,7 +59,7 @@ def get_token_data( return token_data -async def get_user_id_from_token_with_scopes( +def get_user_id_from_token_with_scopes( scopes: list[list[ScopeType]], token_data: schemas_auth.TokenData, ) -> str: diff --git a/tests/test_myeclpay.py b/tests/test_myeclpay.py index 99f11199db..4661c5cf94 100644 --- a/tests/test_myeclpay.py +++ b/tests/test_myeclpay.py @@ -14,6 +14,9 @@ from app.core.groups.groups_type import GroupType from app.core.memberships import models_memberships from app.core.myeclpay import cruds_myeclpay, models_myeclpay +from app.core.myeclpay.coredata_myeclpay import ( + MyECLPayBankAccountHolder, +) from app.core.myeclpay.schemas_myeclpay import QRCodeContentData from app.core.myeclpay.types_myeclpay import ( TransactionStatus, @@ -25,6 +28,7 @@ from app.core.myeclpay.utils_myeclpay import LATEST_TOS from app.core.users import models_users from tests.commons import ( + add_coredata_to_db, add_object_to_db, create_api_access_token, create_user_with_groups, @@ -35,6 +39,8 @@ admin_user_token: str structure_manager_user: models_users.CoreUser structure_manager_user_token: str +structure2_manager_user: models_users.CoreUser +structure2_manager_user_token: str ecl_user: models_users.CoreUser ecl_user_access_token: str @@ -55,8 +61,11 @@ association_membership: models_memberships.CoreAssociationMembership association_membership_user: models_memberships.CoreAssociationUserMembership structure: models_myeclpay.Structure +structure2: models_myeclpay.Structure store_wallet: models_myeclpay.Wallet store: models_myeclpay.Store +store2: models_myeclpay.Store +store3: models_myeclpay.Store store_wallet_device_private_key: Ed25519PrivateKey store_wallet_device: models_myeclpay.WalletDevice @@ -68,6 +77,12 @@ used_qr_code: models_myeclpay.UsedQRCode +invoice1: models_myeclpay.Invoice +invoice2: models_myeclpay.Invoice +invoice3: models_myeclpay.Invoice +invoice1_detail: models_myeclpay.InvoiceDetail +invoice2_detail: models_myeclpay.InvoiceDetail +invoice3_detail: models_myeclpay.InvoiceDetail store_seller_can_bank_user: models_users.CoreUser store_seller_no_permission_user_access_token: str @@ -96,7 +111,13 @@ async def init_objects() -> None: ) await add_object_to_db(association_membership) - global structure_manager_user, structure_manager_user_token, structure + global \ + structure_manager_user, \ + structure_manager_user_token, \ + structure, \ + structure2_manager_user, \ + structure2_manager_user_token, \ + structure2 structure_manager_user = await create_user_with_groups(groups=[]) structure_manager_user_token = create_api_access_token(structure_manager_user) @@ -104,12 +125,45 @@ async def init_objects() -> None: structure = models_myeclpay.Structure( id=uuid4(), name="Test Structure", + creation=datetime.now(UTC), association_membership_id=association_membership.id, manager_user_id=structure_manager_user.id, + short_id="ABC", + siege_address_street="123 Test Street", + siege_address_city="Test City", + siege_address_zipcode="12345", + siege_address_country="Test Country", + siret="12345678901234", + iban="FR76 1234 5678 9012 3456 7890 123", + bic="AZERTYUIOP", ) - await add_object_to_db(structure) + await add_coredata_to_db( + MyECLPayBankAccountHolder( + holder_structure_id=structure.id, + ), + ) + + structure2_manager_user = await create_user_with_groups(groups=[]) + structure2_manager_user_token = create_api_access_token(structure2_manager_user) + + structure2 = models_myeclpay.Structure( + id=uuid4(), + name="Test Structure 2", + creation=datetime.now(UTC), + manager_user_id=structure_manager_user.id, + short_id="XYZ", + siege_address_street="456 Test Street", + siege_address_city="Test City 2", + siege_address_zipcode="67890", + siege_address_country="Test Country 2", + siret="23456789012345", + iban="FR76 1234 5678 9012 3456 7890 123", + bic="AZERTYUIOP", + ) + await add_object_to_db(structure2) + # ecl_user global ecl_user, ecl_user_access_token, association_membership_user @@ -222,15 +276,45 @@ async def init_objects() -> None: balance=5000, # 50€ ) await add_object_to_db(store_wallet) + store2_wallet = models_myeclpay.Wallet( + id=uuid4(), + type=WalletType.STORE, + balance=5000, # 50€ + ) + await add_object_to_db(store2_wallet) + store3_wallet = models_myeclpay.Wallet( + id=uuid4(), + type=WalletType.STORE, + balance=5000, # 50€ + ) + await add_object_to_db(store3_wallet) - global store + global store, store2 store = models_myeclpay.Store( id=uuid4(), wallet_id=store_wallet.id, name="Test Store", structure_id=structure.id, + creation=datetime.now(UTC), ) await add_object_to_db(store) + store2 = models_myeclpay.Store( + id=uuid4(), + wallet_id=store2_wallet.id, + name="Test Store 2", + structure_id=structure2.id, + creation=datetime.now(UTC), + ) + await add_object_to_db(store2) + store3 = models_myeclpay.Store( + id=uuid4(), + wallet_id=store3_wallet.id, + name="Test Store 3", + structure_id=structure2.id, + creation=datetime.now(UTC), + ) + await add_object_to_db(store3) + manager_as_admin = models_myeclpay.Seller( user_id=structure_manager_user.id, store_id=store.id, @@ -443,6 +527,68 @@ async def init_objects() -> None: ) unregistered_ecl_user_access_token = create_api_access_token(unregistered_ecl_user) + global \ + invoice1, \ + invoice1_detail, \ + invoice2, \ + invoice2_detail, \ + invoice3, \ + invoice3_detail + invoice1 = models_myeclpay.Invoice( + id=uuid4(), + reference=f"MYPAY{datetime.now(UTC).year}{structure.short_id}0001", + structure_id=structure.id, + creation=datetime.now(UTC), + total=1000, + paid=True, + received=True, + start_date=datetime.now(UTC) - timedelta(days=30), + end_date=datetime.now(UTC) - timedelta(days=20), + ) + await add_object_to_db(invoice1) + invoice1_detail = models_myeclpay.InvoiceDetail( + invoice_id=invoice1.id, + store_id=store.id, + total=1000, + ) + await add_object_to_db(invoice1_detail) + invoice2 = models_myeclpay.Invoice( + id=uuid4(), + reference=f"MYPAY{datetime.now(UTC).year}{structure.short_id}0002", + structure_id=structure.id, + creation=datetime.now(UTC), + total=1000, + paid=False, + received=False, + start_date=datetime.now(UTC) - timedelta(days=20), + end_date=datetime.now(UTC) - timedelta(days=10), + ) + await add_object_to_db(invoice2) + invoice2_detail = models_myeclpay.InvoiceDetail( + invoice_id=invoice2.id, + store_id=store.id, + total=1000, + ) + await add_object_to_db(invoice2_detail) + invoice3 = models_myeclpay.Invoice( + id=uuid4(), + reference=f"MYPAY{datetime.now(UTC).year}{structure2.short_id}0001", + structure_id=structure2.id, + creation=datetime.now(UTC), + total=1000, + paid=False, + received=False, + start_date=datetime.now(UTC) - timedelta(days=30), + end_date=datetime.now(UTC) - timedelta(days=20), + ) + await add_object_to_db(invoice3) + invoice3_detail = models_myeclpay.InvoiceDetail( + invoice_id=invoice3.id, + store_id=store2.id, + total=1000, + ) + await add_object_to_db(invoice3_detail) + async def test_get_structures(client: TestClient): response = client.get( @@ -450,7 +596,7 @@ async def test_get_structures(client: TestClient): headers={"Authorization": f"Bearer {ecl_user_access_token}"}, ) assert response.status_code == 200 - assert len(response.json()) == 1 + assert len(response.json()) == 2 async def test_create_structure(client: TestClient): @@ -461,6 +607,14 @@ async def test_create_structure(client: TestClient): "name": "Test Structure USEECL", "association_membership_id": str(association_membership.id), "manager_user_id": structure_manager_user.id, + "short_id": "TUS", + "siege_address_street": "123 Test Street", + "siege_address_city": "Test City", + "siege_address_zipcode": "12345", + "siege_address_country": "Test Country", + "siret": "12345678901236", + "iban": "FR76 1234 5678 9012 3456 7890 124", + "bic": "BNPAFRPPXXX", }, ) assert response.status_code == 201 @@ -521,8 +675,17 @@ async def test_delete_structure_as_admin_with_stores(client: TestClient): async def test_delete_structure_as_admin(client: TestClient): new_structure = models_myeclpay.Structure( id=uuid4(), - name="Test Structure 2", + short_id="TSA", + name="Test Structure add", + creation=datetime.now(UTC), manager_user_id=structure_manager_user.id, + siege_address_street="123 Test Street", + siege_address_city="Test City", + siege_address_zipcode="12345", + siege_address_country="Test Country", + siret="12345678901235", + iban="FR76 1234 5678 9012 3456 7890 123", + bic="AZERTYUIOP", ) await add_object_to_db(new_structure) response = client.delete( @@ -600,8 +763,17 @@ async def test_transfer_structure_manager_as_manager( ): new_structure = models_myeclpay.Structure( id=uuid4(), - name="Test Structure 2", + name="Test Structure 3", manager_user_id=structure_manager_user.id, + creation=datetime.now(UTC), + short_id="TS3", + siege_address_street="123 Test Street", + siege_address_city="Test City", + siege_address_zipcode="12345", + siege_address_country="Test Country", + siret="12345678901235", + iban="FR76 1234 5678 9012 3456 7890 123", + bic="AZERTYUIOP", ) await add_object_to_db(new_structure) new_wallet = models_myeclpay.Wallet( @@ -612,6 +784,7 @@ async def test_transfer_structure_manager_as_manager( await add_object_to_db(new_wallet) new_store = models_myeclpay.Store( id=uuid4(), + creation=datetime.now(UTC), wallet_id=new_wallet.id, name="Test Store Structure 2", structure_id=new_structure.id, @@ -625,6 +798,7 @@ async def test_transfer_structure_manager_as_manager( await add_object_to_db(new_wallet2) new_store2_where_new_manager_already_seller = models_myeclpay.Store( id=uuid4(), + creation=datetime.now(UTC), wallet_id=new_wallet2.id, name="Test Store Structure 2 Where New Manager Already Seller", structure_id=new_structure.id, @@ -901,6 +1075,7 @@ async def test_delete_store(client: TestClient): await add_object_to_db(new_wallet) new_store = models_myeclpay.Store( id=store_id, + creation=datetime.now(UTC), wallet_id=new_wallet.id, name="Test Store to Delete", structure_id=structure.id, @@ -934,8 +1109,9 @@ async def test_update_store(client: TestClient): await add_object_to_db(new_wallet) new_store = models_myeclpay.Store( id=uuid4(), + creation=datetime.now(UTC), wallet_id=new_wallet.id, - name="Test Store 2", + name="Test Store Update", structure_id=structure.id, ) await add_object_to_db(new_store) @@ -943,7 +1119,7 @@ async def test_update_store(client: TestClient): f"/myeclpay/stores/{new_store.id}", headers={"Authorization": f"Bearer {structure_manager_user_token}"}, json={ - "name": "Test Store 2 Updated", + "name": "Test Store Updated", }, ) assert response.status_code == 204 @@ -2606,3 +2782,270 @@ async def test_transaction_refund_partial(client: TestClient): credited_wallet_after_refund.balance == credited_wallet_before_refund.balance - 50 ) + + +async def test_get_invoices_as_random_user(client: TestClient): + response = client.get( + "/myeclpay/invoices", + headers={"Authorization": f"Bearer {structure2_manager_user_token}"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "User is not the bank account holder" + + +async def test_get_invoices_as_bank_account_holder(client: TestClient): + response = client.get( + "/myeclpay/invoices", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + + assert response.status_code == 200 + assert len(response.json()) == 3 + + +async def test_get_invoices_as_bank_account_holder_with_date( + client: TestClient, +): + response = client.get( + "/myeclpay/invoices", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + params={ + "start_date": (datetime.now(UTC) - timedelta(days=40)).strftime( + "%Y-%m-%dT%H:%M:%SZ", + ), + "end_date": (datetime.now(UTC) - timedelta(days=15)).strftime( + "%Y-%m-%dT%H:%M:%SZ", + ), + }, + ) + + assert response.status_code == 200 + assert len(response.json()) == 2 + + +async def test_get_invoices_as_bank_account_holder_with_structure_id( + client: TestClient, +): + response = client.get( + f"/myeclpay/invoices?structures_ids={structure2.id}", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + + assert response.status_code == 200 + assert len(response.json()) == 1 + + +async def test_get_invoices_as_bank_account_holder_with_limit( + client: TestClient, +): + response = client.get( + "/myeclpay/invoices", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + params={ + "page": 1, + "page_size": 1, + }, + ) + + assert response.status_code == 200 + assert len(response.json()) == 1 + + # Check that the first invoice is the most recent one + invoices = response.json() + assert invoices[0]["id"] == str( + invoice2.id, + ) + + +async def test_get_structure_invoices_as_structure_manager( + client: TestClient, +): + response = client.get( + f"/myeclpay/invoices/structures/{structure.id}", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + + assert response.status_code == 200 + assert len(response.json()) == 2 + + +async def test_generate_invoice_as_structure_manager( + client: TestClient, +): + response = client.post( + f"/myeclpay/invoices/structures/{structure.id}", + headers={"Authorization": f"Bearer {structure2_manager_user_token}"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "User is not the bank account holder" + + +async def test_generate_invoice_as_bank_account_holder( + client: TestClient, +): + response = client.post( + f"/myeclpay/invoices/structures/{structure2.id}", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + + assert response.status_code == 201 + assert response.json()["id"] is not None + assert response.json()["structure_id"] == str(structure2.id) + assert ( + response.json()["reference"] + == f"MYPAY{datetime.now(UTC).year}{structure2.short_id}0002" + ) + + assert response.json()["total"] == 9000 + + invoices = client.get( + f"/myeclpay/invoices/structures/{structure2.id}", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + assert len(invoices.json()) == 2 + + +async def test_empty_invoice_on_null_details( + client: TestClient, +): + response = client.post( + f"/myeclpay/invoices/structures/{structure2.id}", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "No invoice to create" + + +async def test_update_invoice_paid_status_as_structure_manager( + client: TestClient, +): + response = client.patch( + f"/myeclpay/invoices/{invoice3.id}/paid", + headers={"Authorization": f"Bearer {structure2_manager_user_token}"}, + params={"paid": True}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "User is not the bank account holder" + + +async def test_update_invoice_paid_status_as_bank_account_holder( + client: TestClient, +): + response = client.patch( + f"/myeclpay/invoices/{invoice2.id}/paid", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + params={"paid": True}, + ) + + assert response.status_code == 204, response.text + + async with get_TestingSessionLocal()() as db: + invoice = await cruds_myeclpay.get_invoice_by_id( + db=db, + invoice_id=invoice2.id, + ) + assert invoice is not None + assert invoice.paid is True + + response = client.patch( + f"/myeclpay/invoices/{invoice2.id}/paid", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + params={"paid": False}, + ) + + assert response.status_code == 204 + + async with get_TestingSessionLocal()() as db: + invoice = await cruds_myeclpay.get_invoice_by_id( + db=db, + invoice_id=invoice2.id, + ) + assert invoice is not None + assert invoice.paid is False + + +async def test_update_invoice_received_status_as_structure_manager( + client: TestClient, +): + async with get_TestingSessionLocal()() as db: + await cruds_myeclpay.update_invoice_paid_status( + db=db, + invoice_id=invoice2.id, + paid=True, + ) + await db.commit() + invoice = await cruds_myeclpay.get_invoice_by_id( + db=db, + invoice_id=invoice2.id, + ) + assert invoice is not None + + store_wallet = await cruds_myeclpay.get_wallet( + db=db, + wallet_id=invoice.details[0].store.wallet_id, + ) + assert store_wallet is not None + store_balance = store_wallet.balance + + response = client.patch( + f"/myeclpay/invoices/{invoice2.id}/received", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + assert response.status_code == 204, response.text + + async with get_TestingSessionLocal()() as db: + invoice = await cruds_myeclpay.get_invoice_by_id( + db=db, + invoice_id=invoice2.id, + ) + assert invoice is not None + assert invoice.received is True + + store_wallet = await cruds_myeclpay.get_wallet( + db=db, + wallet_id=invoice.details[0].store.wallet_id, + ) + assert store_wallet is not None + assert store_wallet.balance == store_balance - invoice.details[0].total + + withdrawals = await cruds_myeclpay.get_withdrawals_by_wallet_id( + db=db, + wallet_id=store_wallet.id, + ) + assert len(withdrawals) == 1 + assert withdrawals[0].total == invoice.details[0].total + + +async def test_delete_paid_invoice( + client: TestClient, +): + response = client.delete( + f"/myeclpay/invoices/{invoice2.id}", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + assert response.status_code == 400, response.text + assert ( + response.json()["detail"] + == "Cannot delete an invoice that has already been paid" + ) + + +async def test_delete_invoice( + client: TestClient, +): + response = client.delete( + f"/myeclpay/invoices/{invoice3.id}", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + assert response.status_code == 204, response.text + + response = client.get( + "/myeclpay/invoices", + headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + ) + assert response.status_code == 200 + assert not any(invoice["id"] == invoice3.id for invoice in response.json()) From ee8103a7d662c5edf19299185f5f5eb01e676c63 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sat, 1 Nov 2025 03:52:35 +0100 Subject: [PATCH 2/9] feat: add migration and pdf template --- assets/templates/mypayment_invoice.html | 147 +++++++++++++ assets/templates/output.css | 108 +++++++--- migrations/versions/44-myeclpay_invoices.py | 227 ++++++++++++++++++++ 3 files changed, 448 insertions(+), 34 deletions(-) create mode 100644 assets/templates/mypayment_invoice.html create mode 100644 migrations/versions/44-myeclpay_invoices.py diff --git a/assets/templates/mypayment_invoice.html b/assets/templates/mypayment_invoice.html new file mode 100644 index 0000000000..723686094a --- /dev/null +++ b/assets/templates/mypayment_invoice.html @@ -0,0 +1,147 @@ + + + + + Invoice + + + + + +
+
+

+ {{invoice.structure.name}} +

+

+ {{invoice.structure.siege_address_street}}
+ {{invoice.structure.siege_address_zipcode}} + {{invoice.structure.siege_address_city}}
+ {{invoice.structure.siege_address_country}}
+ {% if invoice.structure.siret %} SIRET : + {{invoice.structure.siret}}
+ {% endif %} +

+
+
+

Facture : {{invoice.reference}}

+

+ {{invoice.structure.siege_address_city}}, le + {{invoice.creation.strftime("%d/%m/%Y")}} +

+
+
+ +
+

Adressée à :

+

+ {{holder_coordinates.name}}
+ {{holder_coordinates.address_street}}
+ {{holder_coordinates.address_zipcode}} + {{holder_coordinates.address_city}}
+ {{holder_coordinates.address_country}}
+ {% if holder_coordinates.siret %} SIRET: {{holder_coordinates.siret}}
+ {% endif %} +

+
+ + + + + + + + + + + + + + +
DescriptionTotal
+ Solde {{payment_name}} du + {{invoice.start_date.strftime("%d/%m/%Y")}} au + {{invoice.end_date.strftime("%d/%m/%Y")}} + {{invoice.total/100}} €
+ +
+ + + + + +
Total :{{invoice.total/100}} €
+
+ +
+ Association à but non lucratif, non assujettie à la TVA (article 261 du + CGI) +
+ +
+

Par virement :

+ + + + + + + + + +
IBAN :{{invoice.structure.iban}}
BIC :{{invoice.structure.bic}}
+
+ +
+

Délai de paiement : 30 jours

+

+ En cas de retard de paiement, seront exigibles, conformément à l'article + L 441-6 du code de commerce, une indemnité calculée sur la base de trois + fois le taux de l'intérêt légal en vigueur ainsi qu'une indemnité + forfaitaire pour frais de recouvrement de 40 euros. +

+
+ +
+

+ Détail de l'utilisation de {{ payment_name }} +

+

Période du 10/01/2025 au 15/02/2025

+ + + + + + + + + {% for detail in invoice.details %} + + + + + {% endfor %} + +
MagasinTotal
{{detail.store.name}}{{detail.total/100}}
+
+ + diff --git a/assets/templates/output.css b/assets/templates/output.css index 381a8bd571..5915eb58b3 100644 --- a/assets/templates/output.css +++ b/assets/templates/output.css @@ -579,6 +579,26 @@ video { margin-right: 2rem; } +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-8 { + margin-bottom: 2rem; +} + .ml-6 { margin-left: 1.5rem; } @@ -603,26 +623,6 @@ video { margin-top: 2rem; } -.mb-2 { - margin-bottom: 0.5rem; -} - -.mb-6 { - margin-bottom: 1.5rem; -} - -.mb-1 { - margin-bottom: 0.25rem; -} - -.mb-3 { - margin-bottom: 0.75rem; -} - -.mb-4 { - margin-bottom: 1rem; -} - .flex { display: flex; } @@ -691,12 +691,16 @@ video { align-items: center; } +.justify-end { + justify-content: flex-end; +} + .justify-center { justify-content: center; } -.gap-4 { - gap: 1rem; +.justify-between { + justify-content: space-between; } .gap-2 { @@ -707,9 +711,9 @@ video { border-width: 1px; } -.border-slate-500 { +.border-gray-300 { --tw-border-opacity: 1; - border-color: rgb(100 116 139 / var(--tw-border-opacity, 1)); + border-color: rgb(209 213 219 / var(--tw-border-opacity, 1)); } .border-gray-400 { @@ -717,9 +721,14 @@ video { border-color: rgb(156 163 175 / var(--tw-border-opacity, 1)); } -.border-gray-300 { +.border-slate-500 { --tw-border-opacity: 1; - border-color: rgb(209 213 219 / var(--tw-border-opacity, 1)); + border-color: rgb(100 116 139 / var(--tw-border-opacity, 1)); +} + +.bg-gray-100 { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); } .bg-gray-300 { @@ -741,14 +750,31 @@ video { padding-right: 0.75rem; } +.text-left { + text-align: left; +} + +.text-right { + text-align: right; +} + +.font-sans { + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + .text-4xl { font-size: 2.25rem; line-height: 2.5rem; } -.text-xl { - font-size: 1.25rem; - line-height: 1.75rem; +.text-base { + font-size: 1rem; + line-height: 1.5rem; } .text-lg { @@ -761,9 +787,9 @@ video { line-height: 1.25rem; } -.text-base { - font-size: 1rem; - line-height: 1.5rem; +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; } .font-bold { @@ -774,9 +800,13 @@ video { font-weight: 600; } -.text-green-700 { +.italic { + font-style: italic; +} + +.text-gray-600 { --tw-text-opacity: 1; - color: rgb(21 128 61 / var(--tw-text-opacity, 1)); + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); } .text-gray-800 { @@ -784,3 +814,13 @@ video { color: rgb(31 41 55 / var(--tw-text-opacity, 1)); } +.text-gray-900 { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity, 1)); +} + +.text-green-700 { + --tw-text-opacity: 1; + color: rgb(21 128 61 / var(--tw-text-opacity, 1)); +} + diff --git a/migrations/versions/44-myeclpay_invoices.py b/migrations/versions/44-myeclpay_invoices.py new file mode 100644 index 0000000000..1ecda93941 --- /dev/null +++ b/migrations/versions/44-myeclpay_invoices.py @@ -0,0 +1,227 @@ +"""empty message + +Create Date: 2025-07-26 18:06:00.966810 +""" + +from collections.abc import Sequence +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from app.types.sqlalchemy import TZDateTime + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "52ce74575f" +down_revision: str | None = "d1079d6b8e6b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +structure_table = sa.Table( + "myeclpay_structure", + sa.MetaData(), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("short_id", sa.String(), nullable=True, unique=True), + sa.Column("siege_address_street", sa.String(), nullable=True), + sa.Column("siege_address_city", sa.String(), nullable=True), + sa.Column("siege_address_zipcode", sa.String(), nullable=True), + sa.Column("siege_address_country", sa.String(), nullable=True), + sa.Column("siret", sa.String(), nullable=True), + sa.Column("iban", sa.String(), nullable=True), + sa.Column("bic", sa.String(), nullable=True), + sa.Column("creation", TZDateTime(), nullable=True), +) + +store_table = sa.Table( + "myeclpay_store", + sa.MetaData(), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("creation", TZDateTime(), nullable=True), +) + + +def upgrade() -> None: + op.create_table( + "myeclpay_withdrawal", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("wallet_id", sa.Uuid(), nullable=False), + sa.Column("total", sa.Integer(), nullable=False), + sa.Column("creation", TZDateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["wallet_id"], + ["myeclpay_wallet.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "myeclpay_invoice", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("reference", sa.String(), nullable=False), + sa.Column("creation", TZDateTime(), nullable=False), + sa.Column("start_date", TZDateTime(), nullable=False), + sa.Column("end_date", TZDateTime(), nullable=False), + sa.Column("total", sa.Integer(), nullable=False), + sa.Column("structure_id", sa.Uuid(), nullable=False), + sa.Column("paid", sa.Boolean(), nullable=False), + sa.Column("received", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["structure_id"], + ["myeclpay_structure.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("reference"), + ) + op.create_table( + "myeclpay_invoice_detail", + sa.Column("invoice_id", sa.Uuid(), nullable=False), + sa.Column("store_id", sa.Uuid(), nullable=False), + sa.Column("total", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["invoice_id"], + ["myeclpay_invoice.id"], + ), + sa.ForeignKeyConstraint( + ["store_id"], + ["myeclpay_store.id"], + ), + sa.PrimaryKeyConstraint("invoice_id", "store_id"), + ) + op.add_column( + "myeclpay_store", + sa.Column("creation", TZDateTime(), nullable=True), + ) + op.add_column( + "myeclpay_structure", + sa.Column("short_id", sa.String(), nullable=True), + ) + op.add_column( + "myeclpay_structure", + sa.Column("siege_address_street", sa.String(), nullable=True), + ) + op.add_column( + "myeclpay_structure", + sa.Column("siege_address_city", sa.String(), nullable=True), + ) + op.add_column( + "myeclpay_structure", + sa.Column("siege_address_zipcode", sa.String(), nullable=True), + ) + op.add_column( + "myeclpay_structure", + sa.Column("siege_address_country", sa.String(), nullable=True), + ) + op.add_column("myeclpay_structure", sa.Column("siret", sa.String(), nullable=True)) + op.add_column("myeclpay_structure", sa.Column("iban", sa.String(), nullable=True)) + op.add_column("myeclpay_structure", sa.Column("bic", sa.String(), nullable=True)) + op.add_column( + "myeclpay_structure", + sa.Column("creation", TZDateTime(), nullable=True), + ) + op.create_unique_constraint(None, "myeclpay_structure", ["short_id"]) + conn = op.get_bind() + conn.execute( + sa.update( + structure_table, + ).values( + { + "siege_address_street": "To change", + "siege_address_city": "To change", + "siege_address_zipcode": "To change", + "siege_address_country": "To change", + "siret": None, + "iban": "To change", + "bic": "To change", + "creation": datetime(2025, 6, 1, tzinfo=UTC), + }, + ), + ) + conn.execute( + sa.update( + store_table, + ).values( + { + "creation": datetime(2025, 6, 1, tzinfo=UTC), + }, + ), + ) + op.alter_column( + "myeclpay_store", + "creation", + nullable=False, + ) + op.alter_column( + "myeclpay_structure", + "creation", + nullable=False, + ) + op.alter_column( + "myeclpay_structure", + "short_id", + nullable=False, + ) + op.alter_column( + "myeclpay_structure", + "siege_address_street", + nullable=False, + ) + op.alter_column( + "myeclpay_structure", + "siege_address_city", + nullable=False, + ) + op.alter_column( + "myeclpay_structure", + "siege_address_zipcode", + nullable=False, + ) + op.alter_column( + "myeclpay_structure", + "siege_address_country", + nullable=False, + ) + op.alter_column( + "myeclpay_structure", + "iban", + nullable=False, + ) + op.alter_column( + "myeclpay_structure", + "bic", + nullable=False, + ) + + # ### end Alembic commands ###s + + +def downgrade() -> None: + op.drop_table("myeclpay_invoice_detail") + op.drop_table("myeclpay_invoice") + op.drop_table("myeclpay_withdrawal") + op.drop_column("myeclpay_structure", "creation") + op.drop_column("myeclpay_structure", "bic") + op.drop_column("myeclpay_structure", "iban") + op.drop_column("myeclpay_structure", "siret") + op.drop_column("myeclpay_structure", "siege_address_country") + op.drop_column("myeclpay_structure", "siege_address_zipcode") + op.drop_column("myeclpay_structure", "siege_address_city") + op.drop_column("myeclpay_structure", "siege_address_street") + op.drop_column("myeclpay_structure", "short_id") + op.drop_column("myeclpay_store", "creation") + + +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 From 13c7d89cf9841e8e39ff997aae52ef1a97863b22 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sat, 1 Nov 2025 09:40:28 +0100 Subject: [PATCH 3/9] fix: use good import and rename --- app/core/myeclpay/endpoints_myeclpay.py | 4 ++-- .../{mypayment_invoice.html => myeclpay_invoice.html} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename assets/templates/{mypayment_invoice.html => myeclpay_invoice.html} (100%) diff --git a/app/core/myeclpay/endpoints_myeclpay.py b/app/core/myeclpay/endpoints_myeclpay.py index 808db0e6c5..4764724096 100644 --- a/app/core/myeclpay/endpoints_myeclpay.py +++ b/app/core/myeclpay/endpoints_myeclpay.py @@ -71,6 +71,7 @@ get_payment_tool, get_request_id, get_settings, + get_token_data, is_user, is_user_an_ecl_member, is_user_in, @@ -78,7 +79,7 @@ from app.types import standard_responses from app.types.module import CoreModule from app.types.scopes_type import ScopeType -from app.utils.auth.auth_utils import get_token_data, get_user_id_from_token_with_scopes +from app.utils.auth.auth_utils import get_user_id_from_token_with_scopes from app.utils.communication.notifications import NotificationTool from app.utils.mail.mailworker import send_email from app.utils.tools import ( @@ -2643,7 +2644,6 @@ async def download_invoice( async def create_structure_invoice( structure_id: UUID, db: AsyncSession = Depends(get_db), - settings: Settings = Depends(get_settings), token_data: schemas_auth.TokenData = Depends(get_token_data), ): """ diff --git a/assets/templates/mypayment_invoice.html b/assets/templates/myeclpay_invoice.html similarity index 100% rename from assets/templates/mypayment_invoice.html rename to assets/templates/myeclpay_invoice.html From 7448724614df17476d225adcf2bfb832dbdc31a5 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sat, 1 Nov 2025 11:06:13 +0100 Subject: [PATCH 4/9] feat: add factory --- app/core/myeclpay/factory_mypayment.py | 131 +++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 app/core/myeclpay/factory_mypayment.py diff --git a/app/core/myeclpay/factory_mypayment.py b/app/core/myeclpay/factory_mypayment.py new file mode 100644 index 0000000000..1a1d055c92 --- /dev/null +++ b/app/core/myeclpay/factory_mypayment.py @@ -0,0 +1,131 @@ +import random +import uuid +from datetime import UTC, datetime, timedelta + +from faker import Faker +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.myeclpay import cruds_myeclpay, models_myeclpay, schemas_myeclpay +from app.core.myeclpay.types_myeclpay import WalletType +from app.core.users.factory_users import CoreUsersFactory +from app.core.utils.config import Settings +from app.types.factory import Factory + +faker = Faker() + +OTHER_STRUCTURES = 2 + + +class MyPaymentFactory(Factory): + depends_on = [CoreUsersFactory] + + demo_structures_id: list[uuid.UUID] + other_structures_id: list[uuid.UUID] + + other_stores_id: list[list[uuid.UUID]] = [] + other_stores_wallet_id: list[list[uuid.UUID]] = [] + + @classmethod + async def create_structures(cls, db: AsyncSession): + cls.demo_structures_id = [uuid.uuid4() for _ in CoreUsersFactory.demo_users_id] + for i, user_id in enumerate(CoreUsersFactory.demo_users_id): + await cruds_myeclpay.create_structure( + schemas_myeclpay.StructureSimple( + id=uuid.uuid4(), + short_id="".join(faker.random_letters(3)).upper(), + name=CoreUsersFactory.demo_users[i].nickname, + manager_user_id=user_id, + siege_address_street=faker.street_address(), + siege_address_city=faker.city(), + siege_address_zipcode=faker.postcode(), + siege_address_country=faker.country(), + iban=faker.iban(), + bic="".join(faker.random_letters(11)).upper(), + creation=datetime.now(UTC), + ), + db, + ) + cls.other_structures_id = [uuid.uuid4() for _ in range(OTHER_STRUCTURES)] + for i, structure_id in enumerate(cls.other_structures_id): + await cruds_myeclpay.create_structure( + schemas_myeclpay.StructureSimple( + id=structure_id, + short_id="".join(faker.random_letters(3)).upper(), + name=faker.company(), + manager_user_id=CoreUsersFactory.other_users_id[i], + siege_address_street=faker.street_address(), + siege_address_city=faker.city(), + siege_address_zipcode=faker.postcode(), + siege_address_country=faker.country(), + iban=faker.iban(), + bic="".join(faker.random_letters(11)).upper(), + creation=datetime.now(UTC), + ), + db, + ) + + @classmethod + async def create_other_structures_stores(cls, db: AsyncSession): + for structure_id in cls.other_structures_id: + structure_store_ids = [] + structure_wallet_ids = [] + for _ in range(random.randint(2, 4)): # noqa: S311 + store_id = uuid.uuid4() + wallet_id = uuid.uuid4() + structure_store_ids.append(store_id) + structure_wallet_ids.append(wallet_id) + await cruds_myeclpay.create_wallet( + wallet_id=wallet_id, + wallet_type=WalletType.STORE, + balance=100000, + db=db, + ) + await cruds_myeclpay.create_store( + models_myeclpay.Store( + id=store_id, + structure_id=structure_id, + name=faker.company(), + creation=datetime.now(UTC), + wallet_id=wallet_id, + ), + db, + ) + cls.other_stores_id.append(structure_store_ids) + cls.other_stores_wallet_id.append(structure_wallet_ids) + + @classmethod + async def create_other_structures_invoices(cls, db: AsyncSession): + for i, structure_id in enumerate(cls.other_structures_id): + invoice_id = uuid.uuid4() + await cruds_myeclpay.create_invoice( + schemas_myeclpay.InvoiceInfo( + id=invoice_id, + structure_id=structure_id, + reference=faker.bothify(text="MYPAY2025???####"), + total=1000 * len(cls.other_stores_id[i]), + paid=False, + received=False, + start_date=datetime.now(UTC) - timedelta(days=30), + end_date=datetime.now(UTC), + creation=datetime.now(UTC), + details=[ + schemas_myeclpay.InvoiceDetailBase( + invoice_id=invoice_id, + store_id=store_id, + total=1000, + ) + for store_id in cls.other_stores_id[i] + ], + ), + db, + ) + + @classmethod + async def run(cls, db: AsyncSession, settings: Settings) -> None: + await cls.create_structures(db) + await cls.create_other_structures_stores(db) + await cls.create_other_structures_invoices(db) + + @classmethod + async def should_run(cls, db: AsyncSession): + return len(await cruds_myeclpay.get_structures(db=db)) == 0 From c7d2db3cea37a727206234b4919003c6db782676 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sat, 1 Nov 2025 14:23:32 +0100 Subject: [PATCH 5/9] fix: factories --- app/core/myeclpay/endpoints_myeclpay.py | 3 ++- .../myeclpay/{factory_mypayment.py => factory_myeclpay.py} | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename app/core/myeclpay/{factory_mypayment.py => factory_myeclpay.py} (99%) diff --git a/app/core/myeclpay/endpoints_myeclpay.py b/app/core/myeclpay/endpoints_myeclpay.py index 4764724096..ed14dc742b 100644 --- a/app/core/myeclpay/endpoints_myeclpay.py +++ b/app/core/myeclpay/endpoints_myeclpay.py @@ -32,6 +32,7 @@ InvoiceNotFoundAfterCreationError, ReferencedStructureNotFoundError, ) +from app.core.myeclpay.factory_myeclpay import MyECLPayFactory from app.core.myeclpay.integrity_myeclpay import ( format_cancel_log, format_refund_log, @@ -96,7 +97,7 @@ tag="MyECLPay", router=router, payment_callback=validate_transfer_callback, - factory=None, + factory=MyECLPayFactory, ) templates = Jinja2Templates(directory="assets/templates") diff --git a/app/core/myeclpay/factory_mypayment.py b/app/core/myeclpay/factory_myeclpay.py similarity index 99% rename from app/core/myeclpay/factory_mypayment.py rename to app/core/myeclpay/factory_myeclpay.py index 1a1d055c92..c727af281b 100644 --- a/app/core/myeclpay/factory_mypayment.py +++ b/app/core/myeclpay/factory_myeclpay.py @@ -16,7 +16,7 @@ OTHER_STRUCTURES = 2 -class MyPaymentFactory(Factory): +class MyECLPayFactory(Factory): depends_on = [CoreUsersFactory] demo_structures_id: list[uuid.UUID] From c27f35dedaf762911a10aaa4218c3be2efd01425 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sat, 1 Nov 2025 14:28:37 +0100 Subject: [PATCH 6/9] fix: factory call --- app/core/myeclpay/endpoints_myeclpay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/myeclpay/endpoints_myeclpay.py b/app/core/myeclpay/endpoints_myeclpay.py index ed14dc742b..5cf7fe3c66 100644 --- a/app/core/myeclpay/endpoints_myeclpay.py +++ b/app/core/myeclpay/endpoints_myeclpay.py @@ -97,7 +97,7 @@ tag="MyECLPay", router=router, payment_callback=validate_transfer_callback, - factory=MyECLPayFactory, + factory=MyECLPayFactory(), ) templates = Jinja2Templates(directory="assets/templates") From d137e6affd1997a1193f7fe3e4938f385fb34080 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sat, 1 Nov 2025 16:11:51 +0100 Subject: [PATCH 7/9] Bump version to 4.10.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index feae29e311..0d1ee9d6ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [{ name = "AEECL ECLAIR" }] # Hyperion follows Semantic Versioning # https://semver.org/ -version = "4.9.9" +version = "4.10.0" minimal-titan-version-code = 139 requires-python = ">= 3.11, < 3.13" From 6ea414a793059c92ab8c017450e7c7b77b264a2a Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sat, 1 Nov 2025 17:15:08 +0100 Subject: [PATCH 8/9] fix: use utils and remove remaining MyPayment --- app/core/myeclpay/cruds_myeclpay.py | 84 +------------------------ app/core/myeclpay/endpoints_myeclpay.py | 6 +- 2 files changed, 6 insertions(+), 84 deletions(-) diff --git a/app/core/myeclpay/cruds_myeclpay.py b/app/core/myeclpay/cruds_myeclpay.py index 10d399de34..14f178e3e2 100644 --- a/app/core/myeclpay/cruds_myeclpay.py +++ b/app/core/myeclpay/cruds_myeclpay.py @@ -15,6 +15,7 @@ ) from app.core.myeclpay.utils_myeclpay import ( invoice_model_to_schema, + refund_model_to_schema, structure_model_to_schema, ) from app.core.users import schemas_users @@ -789,47 +790,7 @@ async def get_refund_by_transaction_id( .scalars() .first() ) - return ( - schemas_myeclpay.Refund( - id=result.id, - transaction_id=result.transaction_id, - credited_wallet_id=result.credited_wallet_id, - debited_wallet_id=result.debited_wallet_id, - total=result.total, - creation=result.creation, - seller_user_id=result.seller_user_id, - transaction=schemas_myeclpay.Transaction( - id=result.transaction.id, - debited_wallet_id=result.transaction.debited_wallet_id, - credited_wallet_id=result.transaction.credited_wallet_id, - transaction_type=result.transaction.transaction_type, - seller_user_id=result.transaction.seller_user_id, - total=result.transaction.total, - creation=result.transaction.creation, - status=result.transaction.status, - ), - debited_wallet=schemas_myeclpay.WalletInfo( - id=result.debited_wallet.id, - type=result.debited_wallet.type, - owner_name=result.debited_wallet.store.name - if result.debited_wallet.store - else result.debited_wallet.user.full_name - if result.debited_wallet.user - else None, - ), - credited_wallet=schemas_myeclpay.WalletInfo( - id=result.credited_wallet.id, - type=result.credited_wallet.type, - owner_name=result.credited_wallet.store.name - if result.credited_wallet.store - else result.credited_wallet.user.full_name - if result.credited_wallet.user - else None, - ), - ) - if result - else None - ) + return refund_model_to_schema(result) if result else None async def get_refunds_by_wallet_id( @@ -858,46 +819,7 @@ async def get_refunds_by_wallet_id( .scalars() .all() ) - return [ - schemas_myeclpay.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_myeclpay.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_myeclpay.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_myeclpay.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, - ), - ) - for refund in result - ] + return [refund_model_to_schema(refund) for refund in result] async def get_store( diff --git a/app/core/myeclpay/endpoints_myeclpay.py b/app/core/myeclpay/endpoints_myeclpay.py index 5cf7fe3c66..0a9ad42e99 100644 --- a/app/core/myeclpay/endpoints_myeclpay.py +++ b/app/core/myeclpay/endpoints_myeclpay.py @@ -2710,7 +2710,7 @@ async def create_structure_invoice( ) if store_wallet_db is None: hyperion_error_logger.error( - "MyPayment: Could not find wallet associated with a store, this should never happen", + "MyECLPAy: Could not find wallet associated with a store, this should never happen", ) raise HTTPException( status_code=500, @@ -2763,7 +2763,7 @@ async def create_structure_invoice( ) invoice = schemas_myeclpay.InvoiceInfo( id=invoice_id, - reference=f"MYPAY{security_now.year}{structure.short_id}{last_invoice_number + 1:04d}", + reference=f"PAY{security_now.year}{structure.short_id}{last_invoice_number + 1:04d}", structure_id=structure_id, creation=datetime.now(UTC), start_date=last_structure_invoice.end_date @@ -2898,7 +2898,7 @@ async def aknowledge_invoice_as_received( ) if store is None: hyperion_error_logger.error( - "MyPayment: Could not find store associated with an invoice, this should never happen", + "MyECLPay: Could not find store associated with an invoice, this should never happen", ) raise HTTPException( status_code=500, From cefd6fda44a689639aa4d8aac1c90a8439ae064e Mon Sep 17 00:00:00 2001 From: Marc-Andrieu <146140470+Marc-Andrieu@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:52:22 +0100 Subject: [PATCH 9/9] fix: it's PAY not MYPAY --- tests/test_myeclpay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_myeclpay.py b/tests/test_myeclpay.py index 4661c5cf94..878d45e3db 100644 --- a/tests/test_myeclpay.py +++ b/tests/test_myeclpay.py @@ -2895,7 +2895,7 @@ async def test_generate_invoice_as_bank_account_holder( assert response.json()["structure_id"] == str(structure2.id) assert ( response.json()["reference"] - == f"MYPAY{datetime.now(UTC).year}{structure2.short_id}0002" + == f"PAY{datetime.now(UTC).year}{structure2.short_id}0002" ) assert response.json()["total"] == 9000