Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 23 additions & 19 deletions app/core/mypayment/cruds_mypayment.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app.core.mypayment.exceptions_mypayment import WalletNotFoundOnUpdateError
from app.core.mypayment.types_mypayment import (
TransactionStatus,
TransactionType,
WalletDeviceStatus,
WalletType,
)
Expand Down Expand Up @@ -961,40 +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)
db.add(used_transaction_request)


async def get_used_qrcode(
qr_code_id: UUID,
async def get_used_transaction_request(
transaction_request_id: UUID,
db: AsyncSession,
) -> models_mypayment.UsedQRCode | None:
) -> models_mypayment.UsedTransactionRequest | None:
result = await db.execute(
select(models_mypayment.UsedQRCode).where(
models_mypayment.UsedQRCode.qr_code_id == qr_code_id,
select(models_mypayment.UsedTransactionRequest).where(
models_mypayment.UsedTransactionRequest.id == transaction_request_id,
),
)
return result.scalars().first()


async def delete_used_qrcode(
qr_code_id: UUID,
async def delete_used_transaction_request(
transaction_request_id: UUID,
db: AsyncSession,
) -> None:
await db.execute(
delete(models_mypayment.UsedQRCode).where(
models_mypayment.UsedQRCode.qr_code_id == qr_code_id,
delete(models_mypayment.UsedTransactionRequest).where(
models_mypayment.UsedTransactionRequest.id == transaction_request_id,
),
)

Expand Down
261 changes: 251 additions & 10 deletions app/core/mypayment/endpoints_mypayment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1261,6 +1262,8 @@
db=db,
)

await db.flush()

hyperion_mypayment_logger.info(
wallet_id,
extra={
Expand Down Expand Up @@ -2087,13 +2090,14 @@

`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
}
```

Expand All @@ -2109,8 +2113,8 @@
**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:
Expand All @@ -2121,15 +2125,16 @@

# 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,
Expand Down Expand Up @@ -2309,6 +2314,242 @@
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 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*,
- where the signed data is a `TransactionRequestInfo`:
```
{
id: UUID
tot: int
iat: datetime
key: UUID
}
```

The provided content is checked to ensure:
- 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

**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_transaction_request(
transaction_request_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_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 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,
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",
)

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",
)

# 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(
seconds=PURCHASE_EXPIRATION,
):
raise HTTPException(
status_code=400,
detail="Purchase 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 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",
)
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",
)

# 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(
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,

Check failure on line 2544 in app/core/mypayment/endpoints_mypayment.py

View workflow job for this annotation

GitHub Actions / lintandformat

Ruff (F821)

app/core/mypayment/endpoints_mypayment.py:2544:27: F821 Undefined name `settings`
)
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,
Expand Down
Loading
Loading