From 2e86b61d53140bd402a57a380060fc4e9b7128c8 Mon Sep 17 00:00:00 2001 From: Marc-Andrieu Date: Mon, 10 Nov 2025 15:50:29 +0100 Subject: [PATCH 1/4] Payment: add classical purchase as a new means of payment --- app/core/mypayment/cruds_mypayment.py | 36 ++++ app/core/mypayment/endpoints_mypayment.py | 242 +++++++++++++++++++++- app/core/mypayment/models_mypayment.py | 13 ++ app/core/mypayment/schemas_mypayment.py | 28 ++- app/core/mypayment/types_mypayment.py | 5 + app/core/mypayment/utils_mypayment.py | 4 +- 6 files changed, 312 insertions(+), 16 deletions(-) diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index a4321adcc5..d356a8fcf7 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -999,6 +999,42 @@ async def delete_used_qrcode( ) +async def create_used_payment( + payment: schemas_myeclpay.PurchaseInfo, + db: AsyncSession, +) -> None: + wallet = models_myeclpay.UsedPurchase( + payment_id=payment.id, + payment_tot=payment.tot, + payment_iat=payment.iat, + payment_key=payment.key, + signature=payment.signature, + ) + db.add(wallet) + + +async def get_used_payment( + payment_id: UUID, + db: AsyncSession, +) -> models_myeclpay.UsedPurchase | None: + result = await db.execute( + select(models_myeclpay.UsedPurchase).where( + models_myeclpay.UsedPurchase.payment_id == payment_id, + ), + ) + return result.scalars().first() + + +async def delete_used_payment( + payment_id: UUID, + db: AsyncSession, +) -> None: + await db.execute( + delete(models_myeclpay.UsedPurchase).where( + models_myeclpay.UsedPurchase.payment_id == payment_id, + ), + ) + async def get_invoices( db: AsyncSession, skip: int | None = None, diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index 2bf4b1e416..7d50aa3293 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -1261,6 +1261,8 @@ async def register_user( db=db, ) + await db.flush() + hyperion_mypayment_logger.info( wallet_id, extra={ @@ -2087,13 +2089,14 @@ async def store_scan_qrcode( `signature` should be a base64 encoded string - signed using *ed25519*, - - where data are a `QRCodeContentData` object: + - where the signed data is a `TransactionRequestInfo` plus the `store boolean` object: ``` { - id: UUID - tot: int - iat: datetime - key: UUID + id: UUID, + tot: int, + iat: datetime, + key: UUID, + store: bool } ``` @@ -2309,6 +2312,235 @@ async def store_scan_qrcode( return transaction +@router.post( + "/mypayment/stores/{store_id}/purchase", + response_model=standard_responses.Result, + status_code=200, +) +async def user_purchase_store( + store_id: UUID, + purchase_info: schemas_mypayment.PurchaseInfo, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_an_ecl_member), + request_id: str = Depends(get_request_id), + notification_tool: NotificationTool = Depends(get_notification_tool), +): + """ + Bank a transation to a store at the user's request (whereas a scan is performed by a seller) + + `signature` should be a base64 encoded string + - signed using *ed25519*, + - where the signed data is a `TransactionRequestInfo`: + ``` + { + id: UUID + tot: int + iat: datetime + key: UUID + } + ``` + + The provided content is checked to ensure: + - the payment information has not already been used to bank a transfer + - the transfer information is not expired + - the transfer is intended for an existing store + - the signature is valid and correspond to `wallet_device_id` public key + - the debited's wallet device is active + - the debited's Wallet balance greater than the total + + **The user must be authenticated to use this endpoint** + """ + # If the payment is already done, we return an error + already_existing_used_payment = await cruds_mypayment.get_used_payment( + payment_id=purchase_info.id, + db=db, + ) + if already_existing_used_payment is not None: + raise HTTPException( + status_code=409, + detail="Payment already made", + ) + + # After paying, we want to add it to the list of already made payments + # even if it fail to be banked + await cruds_mypayment.create_used_payment( + payment=purchase_info, + db=db, + ) + + await db.flush() + + # We start a SAVEPOINT to ensure that even if the following code fails due to a database exception, + # after rollback the `UsedPayment` will still be created and committed in db. + async with db.begin_nested(): + store = await cruds_mypayment.get_store( + store_id=store_id, + db=db, + ) + if store is None: + raise HTTPException( + status_code=404, + detail="Store does not exist", + ) + + # We verify the signature + debited_wallet_device = await cruds_mypayment.get_wallet_device( + wallet_device_id=purchase_info.key, + db=db, + ) + + if debited_wallet_device is None: + raise HTTPException( + status_code=400, + detail="Wallet device does not exist", + ) + + if debited_wallet_device.status != WalletDeviceStatus.ACTIVE: + raise HTTPException( + status_code=400, + detail="Wallet device is not active", + ) + + # TODO: it's not a QRCodeContent but a Payment-related thingy + if not verify_signature( + public_key_bytes=debited_wallet_device.ed25519_public_key, + signature=purchase_info.signature, + data=purchase_info, + wallet_device_id=purchase_info.key, + request_id=request_id, + ): + raise HTTPException( + status_code=400, + detail="Invalid signature", + ) + + # TODO: check the credited wallet exists + + # We verify the content respect some rules + if purchase_info.tot <= 0: + raise HTTPException( + status_code=400, + detail="Total must be greater than 0", + ) + + if purchase_info.iat < datetime.now(UTC) - timedelta( + minutes=QRCODE_EXPIRATION, # TODO: is it relevant? + ): + raise HTTPException( + status_code=400, + detail="Payment information is expired", + ) + + # 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.id != user.id: + hyperion_error_logger.error( + f"MyPayment: Mismatch between the user {user.id} who sent the request and the user {debited_wallet.user.id} owning the signatory device, this should never happen", + ) + raise HTTPException( + status_code=400, + detail="MyPayment: Mismatch between the user who sent the request and the user owning the signatory device", + ) + + 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 < purchase_info.tot: + raise HTTPException( + status_code=400, + detail="Insufficient balance in the debited wallet", + ) + + # We check if the user is a member of the association + # and raise an error if not + if store.structure.association_membership_id is not None: + current_membership = ( + await get_user_active_membership_to_association_membership( + user_id=debited_wallet.user.id, + association_membership_id=store.structure.association_membership_id, + db=db, + ) + ) + if current_membership is None: + raise HTTPException( + status_code=400, + 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=purchase_info.tot, + db=db, + ) + + # We decrement the debited wallet balance + await cruds_mypayment.increment_wallet_balance( + wallet_id=debited_wallet.id, + amount=-purchase_info.tot, + db=db, + ) + transaction_id = uuid.uuid4() + creation_date = datetime.now(UTC) + transaction = schemas_mypayment.TransactionBase( + id=transaction_id, + debited_wallet_id=debited_wallet_device.wallet_id, + credited_wallet_id=store.wallet_id, + transaction_type=TransactionType.INDIRECT, + seller_user_id=debited_wallet.user.id, + total=purchase_info.tot, + creation=creation_date, + status=TransactionStatus.CONFIRMED, + qr_code_id=purchase_info.id, + ) + # 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 {purchase_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 + + @router.post( "/mypayment/transactions/{transaction_id}/refund", status_code=204, diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index b6af28ce2f..cf010757a7 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -252,6 +252,19 @@ class UsedQRCode(Base): signature: Mapped[str | None] +# TODO: merge these two tables into UsedTransactionRequest, with the direct-ness of the request +# Then merge the cruds, and stop there + + +class UsedPurchase(Base): + __tablename__ = "myeclpay_used_payment" + + payment_id: Mapped[PrimaryKey] + payment_tot: Mapped[int | None] + payment_iat: Mapped[datetime | None] + payment_key: Mapped[UUID | None] + signature: Mapped[str | None] + class Invoice(Base): __tablename__ = "mypayment_invoice" diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 8825a457de..c1bcc18c05 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -163,31 +163,41 @@ class History(BaseModel): refund: HistoryRefund | None = None -class QRCodeContentData(BaseModel): +class TransactionRequestInfo(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. + Format of the data stored in a transaction request, either to pay directly of have a QR code scanned. + This data will be signed using ed25519 and the private key of the WalletDevice that allowed to be debited. 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 + key: UUID # debited wallet id + signature: str -class ScanInfo(QRCodeContentData): - signature: str +class ScanInfo(TransactionRequestInfo): + """ + Information encoded in a QR code, the seller sends a request + store: If the QR Code is intended to be scanned for a Store Wallet, or for an other user Wallet + """ + + store: bool bypass_membership: bool = False +class PurchaseInfo(TransactionRequestInfo): + """ + Information for a classical payment, the buyer sends a request. + There is nothing to add. + """ + + class WalletBase(BaseModel): id: UUID type: WalletType diff --git a/app/core/mypayment/types_mypayment.py b/app/core/mypayment/types_mypayment.py index 2fce885bc4..784ffe3af0 100644 --- a/app/core/mypayment/types_mypayment.py +++ b/app/core/mypayment/types_mypayment.py @@ -15,14 +15,19 @@ class WalletDeviceStatus(str, Enum): class TransactionType(str, Enum): DIRECT = "direct" + INDIRECT = "indirect" REQUEST = "request" REFUND = "refund" class HistoryType(str, Enum): TRANSFER = "transfer" + RECEIVED = "received" GIVEN = "given" + INDIRECT_GIVEN = "indirect_given" + INDIRECT_RECEIVED = "indirect_received" + REFUND_CREDITED = "refund_credited" REFUND_DEBITED = "refund_debited" diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index ff6ea477d6..2097709663 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -11,7 +11,7 @@ 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, + TransactionRequestInfo, ) from app.core.mypayment.types_mypayment import ( TransferAlreadyConfirmedInCallbackError, @@ -34,7 +34,7 @@ def verify_signature( public_key_bytes: bytes, signature: str, - data: QRCodeContentData, + data: TransactionRequestInfo, wallet_device_id: UUID, request_id: str, ) -> bool: From e1d273655cf9de7c6f95fa28ff18f15ce7f66ad8 Mon Sep 17 00:00:00 2001 From: Marc-Andrieu Date: Mon, 10 Nov 2025 16:59:15 +0100 Subject: [PATCH 2/4] Refacto: merge into UsedTransactionRequest and resolve the TODOs --- app/core/mypayment/cruds_mypayment.py | 79 +++++++---------------- app/core/mypayment/endpoints_mypayment.py | 43 ++++++------ app/core/mypayment/models_mypayment.py | 35 ++++------ app/core/mypayment/utils_mypayment.py | 1 + 4 files changed, 59 insertions(+), 99 deletions(-) diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index d356a8fcf7..b1306004a2 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -10,6 +10,7 @@ from app.core.mypayment.exceptions_mypayment import WalletNotFoundOnUpdateError from app.core.mypayment.types_mypayment import ( TransactionStatus, + TransactionType, WalletDeviceStatus, WalletType, ) @@ -961,77 +962,43 @@ async def get_store( return result.scalars().first() -async def create_used_qrcode( - qr_code: schemas_mypayment.ScanInfo, +async def create_used_transaction_request( + transaction_request: schemas_mypayment.TransactionRequestInfo, + transaction_type: TransactionType, db: AsyncSession, ) -> None: - wallet = models_mypayment.UsedQRCode( - qr_code_id=qr_code.id, - qr_code_tot=qr_code.tot, - qr_code_iat=qr_code.iat, - qr_code_key=qr_code.key, - qr_code_store=qr_code.store, - signature=qr_code.signature, + used_transaction_request = models_mypayment.UsedTransactionRequest( + id=transaction_request.id, + tot=transaction_request.tot, + iat=transaction_request.iat, + key=transaction_request.key, + signature=transaction_request.signature, + transaction_type=transaction_type, + store=transaction_request.store if transaction_request.store else None, + store_id=transaction_request.store_id if transaction_request.store_id else None, ) - db.add(wallet) - - -async def get_used_qrcode( - qr_code_id: UUID, - db: AsyncSession, -) -> models_mypayment.UsedQRCode | None: - result = await db.execute( - select(models_mypayment.UsedQRCode).where( - models_mypayment.UsedQRCode.qr_code_id == qr_code_id, - ), - ) - return result.scalars().first() - - -async def delete_used_qrcode( - qr_code_id: UUID, - db: AsyncSession, -) -> None: - await db.execute( - delete(models_mypayment.UsedQRCode).where( - models_mypayment.UsedQRCode.qr_code_id == qr_code_id, - ), - ) - - -async def create_used_payment( - payment: schemas_myeclpay.PurchaseInfo, - db: AsyncSession, -) -> None: - wallet = models_myeclpay.UsedPurchase( - payment_id=payment.id, - payment_tot=payment.tot, - payment_iat=payment.iat, - payment_key=payment.key, - signature=payment.signature, - ) - db.add(wallet) + db.add(used_transaction_request) -async def get_used_payment( - payment_id: UUID, +async def get_used_transaction_request( + transaction_request_id: UUID, db: AsyncSession, -) -> models_myeclpay.UsedPurchase | None: +) -> models_mypayment.UsedTransactionRequest | None: result = await db.execute( - select(models_myeclpay.UsedPurchase).where( - models_myeclpay.UsedPurchase.payment_id == payment_id, + select(models_mypayment.UsedTransactionRequest).where( + models_mypayment.UsedTransactionRequest.id == transaction_request_id, ), ) return result.scalars().first() -async def delete_used_payment( - payment_id: UUID, +async def delete_used_transaction_request( + transaction_request_id: UUID, db: AsyncSession, ) -> None: await db.execute( - delete(models_myeclpay.UsedPurchase).where( - models_myeclpay.UsedPurchase.payment_id == payment_id, + delete(models_mypayment.UsedTransactionRequest).where( + models_mypayment.UsedTransactionRequest.id == transaction_request_id, ), ) diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index 7d50aa3293..9fb5822fde 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -53,6 +53,7 @@ from app.core.mypayment.utils.data_exporter import generate_store_history_csv from app.core.mypayment.utils_mypayment import ( LATEST_TOS, + PURCHASE_EXPIRATION, QRCODE_EXPIRATION, is_user_latest_tos_signed, structure_model_to_schema, @@ -2112,8 +2113,8 @@ async def store_scan_qrcode( **The user must have the `can_bank` permission for this store** """ # If the QR Code is already used, we return an error - already_existing_used_qrcode = await cruds_mypayment.get_used_qrcode( - qr_code_id=scan_info.id, + already_existing_used_qrcode = await cruds_mypayment.get_used_transaction_request( + transaction_request_id=scan_info.id, db=db, ) if already_existing_used_qrcode is not None: @@ -2124,15 +2125,16 @@ async def store_scan_qrcode( # After scanning a QR Code, we want to add it to the list of already scanned QR Code # even if it fail to be banked - await cruds_mypayment.create_used_qrcode( - qr_code=scan_info, + await cruds_mypayment.create_used_transaction_request( + transaction_request=scan_info, + transaction_type=TransactionType.DIRECT, db=db, ) await db.flush() # We start a SAVEPOINT to ensure that even if the following code fails due to a database exception, - # after roleback the `used_qrcode` will still be created and committed in db. + # after rollback the used QR Code will still be created and committed in db. async with db.begin_nested(): store = await cruds_mypayment.get_store( store_id=store_id, @@ -2326,7 +2328,7 @@ async def user_purchase_store( notification_tool: NotificationTool = Depends(get_notification_tool), ): """ - Bank a transation to a store at the user's request (whereas a scan is performed by a seller) + Bank a transaction to a store at the user's request (whereas a scan is performed by a seller) `signature` should be a base64 encoded string - signed using *ed25519*, @@ -2341,9 +2343,9 @@ async def user_purchase_store( ``` The provided content is checked to ensure: - - the payment information has not already been used to bank a transfer - - the transfer information is not expired - - the transfer is intended for an existing store + - the purchase information has not already been used to bank a transfer + - the purchase request is very recent (that is, the information is not expired) + - the purchase is intended for an existing store - the signature is valid and correspond to `wallet_device_id` public key - the debited's wallet device is active - the debited's Wallet balance greater than the total @@ -2351,8 +2353,8 @@ async def user_purchase_store( **The user must be authenticated to use this endpoint** """ # If the payment is already done, we return an error - already_existing_used_payment = await cruds_mypayment.get_used_payment( - payment_id=purchase_info.id, + already_existing_used_payment = await cruds_mypayment.get_used_transaction_request( + transaction_request_id=purchase_info.id, db=db, ) if already_existing_used_payment is not None: @@ -2363,15 +2365,16 @@ async def user_purchase_store( # After paying, we want to add it to the list of already made payments # even if it fail to be banked - await cruds_mypayment.create_used_payment( - payment=purchase_info, + await cruds_mypayment.create_used_transaction_request( + transaction_request=purchase_info, + transaction_type=TransactionType.INDIRECT, db=db, ) await db.flush() # We start a SAVEPOINT to ensure that even if the following code fails due to a database exception, - # after rollback the `UsedPayment` will still be created and committed in db. + # after rollback the used purchase will still be created and committed in db. async with db.begin_nested(): store = await cruds_mypayment.get_store( store_id=store_id, @@ -2401,7 +2404,6 @@ async def user_purchase_store( detail="Wallet device is not active", ) - # TODO: it's not a QRCodeContent but a Payment-related thingy if not verify_signature( public_key_bytes=debited_wallet_device.ed25519_public_key, signature=purchase_info.signature, @@ -2414,8 +2416,6 @@ async def user_purchase_store( detail="Invalid signature", ) - # TODO: check the credited wallet exists - # We verify the content respect some rules if purchase_info.tot <= 0: raise HTTPException( @@ -2424,11 +2424,11 @@ async def user_purchase_store( ) if purchase_info.iat < datetime.now(UTC) - timedelta( - minutes=QRCODE_EXPIRATION, # TODO: is it relevant? + seconds=PURCHASE_EXPIRATION, ): raise HTTPException( status_code=400, - detail="Payment information is expired", + detail="Purchase information is expired", ) # We verify that the debited walled contains enough money @@ -2472,8 +2472,9 @@ async def user_purchase_store( detail="Insufficient balance in the debited wallet", ) - # We check if the user is a member of the association - # and raise an error if not + # In case the store's structure requires a membership, + # we check if the user has it, and raise an error if not. + # Note: unlike the scan method, bypassing the membership is NOT allowed if store.structure.association_membership_id is not None: current_membership = ( await get_user_active_membership_to_association_membership( diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index cf010757a7..7f4313ef33 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -67,7 +67,7 @@ class Transaction(Base): store_note: Mapped[str | None] qr_code_id: Mapped[UUID | None] = mapped_column( - ForeignKey("mypayment_used_qrcode.qr_code_id"), + ForeignKey("mypayment_used_transaction_request.id"), ) debited_wallet: Mapped[Wallet] = relationship( @@ -241,28 +241,19 @@ class UserPayment(Base): accepted_tos_version: Mapped[int] -class UsedQRCode(Base): - __tablename__ = "mypayment_used_qrcode" +class UsedTransactionRequest(Base): + __tablename__ = "mypayment_used_transaction_request" - qr_code_id: Mapped[PrimaryKey] - qr_code_tot: Mapped[int | None] - qr_code_iat: Mapped[datetime | None] - qr_code_key: Mapped[UUID | None] - qr_code_store: Mapped[bool | None] - signature: Mapped[str | None] - - -# TODO: merge these two tables into UsedTransactionRequest, with the direct-ness of the request -# Then merge the cruds, and stop there - - -class UsedPurchase(Base): - __tablename__ = "myeclpay_used_payment" - - payment_id: Mapped[PrimaryKey] - payment_tot: Mapped[int | None] - payment_iat: Mapped[datetime | None] - payment_key: Mapped[UUID | None] + id: Mapped[PrimaryKey] + tot: Mapped[int | None] + iat: Mapped[datetime | None] + key: Mapped[UUID | None] + transaction_type: Mapped[TransactionType.DIRECT | TransactionType.INDIRECT] + store: Mapped[bool | None] + store_id: Mapped[UUID | None] = mapped_column( + ForeignKey("myeclpay_store.id"), + nullable=True, + ) signature: Mapped[str | None] class Invoice(Base): diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 2097709663..732e3789ff 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -27,6 +27,7 @@ LATEST_TOS = 2 QRCODE_EXPIRATION = 5 # minutes +PURCHASE_EXPIRATION = 20 # seconds MYPAYMENT_LOGS_S3_SUBFOLDER = "logs" RETENTION_DURATION = 10 * 365 # 10 years in days From b02e2eb66511648930b777078025428fd4f5c9d7 Mon Sep 17 00:00:00 2001 From: Marc-Andrieu Date: Mon, 10 Nov 2025 17:04:41 +0100 Subject: [PATCH 3/4] fix: Ruff's broker COM812 rule --- app/core/mypayment/cruds_mypayment.py | 1 + app/core/mypayment/models_mypayment.py | 1 + 2 files changed, 2 insertions(+) diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index b1306004a2..6bb20f0a9a 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -1002,6 +1002,7 @@ async def delete_used_transaction_request( ), ) + async def get_invoices( db: AsyncSession, skip: int | None = None, diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index 7f4313ef33..0e32d39884 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -256,6 +256,7 @@ class UsedTransactionRequest(Base): ) signature: Mapped[str | None] + class Invoice(Base): __tablename__ = "mypayment_invoice" From 87b1cc40bc0c4d2a0aed68fadadd3e0c9aa7157d Mon Sep 17 00:00:00 2001 From: Marc-Andrieu Date: Mon, 10 Nov 2025 17:26:23 +0100 Subject: [PATCH 4/4] Partly fix type-checking --- app/core/mypayment/endpoints_mypayment.py | 8 ++++++++ app/core/mypayment/models_mypayment.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index 9fb5822fde..001ee6e8c4 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -2445,6 +2445,14 @@ async def user_purchase_store( detail="Could not find wallet associated with the debited wallet device", ) + if debited_wallet.user is None: + hyperion_error_logger.error( + f"MyECLPay: No UserPayment for debited wallet {debited_wallet.id}, this should never happen", + ) + raise HTTPException( + status_code=400, + detail="MyECLPay: The debited wallet is not associated to a user", + ) if debited_wallet.user.id != user.id: hyperion_error_logger.error( f"MyPayment: Mismatch between the user {user.id} who sent the request and the user {debited_wallet.user.id} owning the signatory device, this should never happen", diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index 0e32d39884..d9bed54212 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -248,7 +248,7 @@ class UsedTransactionRequest(Base): tot: Mapped[int | None] iat: Mapped[datetime | None] key: Mapped[UUID | None] - transaction_type: Mapped[TransactionType.DIRECT | TransactionType.INDIRECT] + transaction_type: Mapped[TransactionType] # Should be DIRECT or DIRECTED store: Mapped[bool | None] store_id: Mapped[UUID | None] = mapped_column( ForeignKey("myeclpay_store.id"),