integrated supabase in payment process
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m13s
Run linting on the backend code / Build (pull_request) Successful in 3m9s
Run testing on the backend code / Build (pull_request) Failing after 2m32s
Build and deploy the backend to staging / Deploy to staging (pull_request) Failing after 35s

This commit is contained in:
2025-10-09 14:31:56 +02:00
parent 29ac462725
commit 54f541382e
6 changed files with 182 additions and 41 deletions

View File

@@ -1,4 +1,6 @@
"""Module used for handling cache"""
import hashlib
from pymemcache import serde
from pymemcache.client.base import Client
@@ -73,3 +75,62 @@ else:
encoding='utf-8',
serde=serde.pickle_serde
)
#### Cache for payment architecture
def make_credit_cache_key(user_id: str, order_id: str) -> str:
"""
Generate a cache key from user_id and order_id using md5.
Args:
user_id (str): The user's ID.
order_id (str): The PayPal order ID.
Returns:
str: A unique cache key.
"""
# Concatenate and hash to avoid collisions and keep key size small
raw_key = f"{user_id}:{order_id}"
return hashlib.md5(raw_key.encode('utf-8')).hexdigest()
class CreditCache:
"""
Handles storing and retrieving credits to grant for a user/order.
Methods:
set_credits(user_id, order_id, credits):
Store the credits for a user/order.
get_credits(user_id, order_id):
Retrieve the credits for a user/order.
"""
@staticmethod
def set_credits(user_id: str, order_id: str, credits_to_grant: int) -> None:
"""
Store the credits to be granted for a user/order.
Args:
user_id (str): The user's ID.
order_id (str): The PayPal order ID.
credits (int): The amount of credits to grant.
"""
cache_key = make_credit_cache_key(user_id, order_id)
client.set(cache_key, credits_to_grant)
@staticmethod
def get_credits(user_id: str, order_id: str) -> int | None:
"""
Retrieve the credits to be granted for a user/order.
Args:
user_id (str): The user's ID.
order_id (str): The PayPal order ID.
Returns:
int | None: The credits to grant, or None if not found.
"""
cache_key = make_credit_cache_key(user_id, order_id)
return client.get(cache_key)

View File

@@ -46,6 +46,8 @@ app.include_router(toilets_router)
# Include the payment router for interacting with paypal sdk.
# See src/payment/payment_router.py for more information on how to call.
# Call with "/orders/new" to initiate a payment with an order request (step 1)
# Call with "/orders/{order_id}/{user_id}capture" to capture a payment and grant the user the due credits (step 2)
app.include_router(payment_router)
@@ -54,3 +56,4 @@ app.include_router(payment_router)
# Call with "/landmark/{landmark_uuid}" for getting landmark by UUID.
# Call with "/trip//trip/recompute-time/{trip_uuid}/{removed_landmark_uuid}" for updating trip times.
app.include_router(trips_router)

View File

@@ -1,20 +1,22 @@
import json
import logging
from typing import Literal
from datetime import datetime, timedelta
import logging
import json
from pydantic import BaseModel, Field, field_validator
import requests
from pydantic import BaseModel, Field, field_validator
from ..configuration.environment import Environment
from ..cache import CreditCache, make_credit_cache_key
# Define the base URL, might move that to toml file
BASE_URL = 'https://api-m.sandbox.paypal.com'
# Intialize the logger
logger = logging.getLogger(__name__)
# Define the base URL, might move that to toml file
BASE_URL_PROD = 'https://api-m.paypal.com'
BASE_URL_SANDBOX = 'https://api-m.sandbox.paypal.com'
class BasketItem(BaseModel):
"""
@@ -42,7 +44,7 @@ class Item(BaseModel):
name: str
description: str
unit_price: float
anyway_credits: int
unit_credits: int
def item_from_sql(item_id: str):
@@ -60,8 +62,8 @@ def item_from_sql(item_id: str):
id = '12345678',
name = 'test_item',
description = 'lorem ipsum',
unit_price = 420,
anyway_credits = 99
unit_price = 0.1,
unit_credits = 5
)
@@ -84,7 +86,8 @@ class OrderRequest(BaseModel):
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
items: list[Item] = Field(default_factory=list)
total_price: float = 0
total_price: float = None
total_credits: int = None
@field_validator('basket')
def validate_basket(cls, v):
@@ -104,15 +107,18 @@ class OrderRequest(BaseModel):
return
def load_items_and_price(self):
# This should be automatic upon initialization of the class
"""
Loads item details from database and calculates the total price.
Loads item details from database and calculates the total price as well as the total credits to be granted.
"""
self.items = []
self.total_price = 0
self.total_credits = 0
for basket_item in self.basket:
item = item_from_sql(basket_item.id)
self.items.append(item)
self.total_price += item.unit_price * basket_item.quantity
self.total_price += item.unit_price * basket_item.quantity # increment price
self.total_credits += item.unit_credits * basket_item.quantity # increment credit balance
def to_paypal_items(self):
@@ -138,11 +144,8 @@ class OrderRequest(BaseModel):
return item_list
# Payment handler class for managing PayPal payments
class PaypalHandler:
class PaypalClient:
"""
Handles PayPal payment operations.
@@ -159,7 +162,6 @@ class PaypalHandler:
"expires_at": 0
}
order_request = None
def __init__(
self,
@@ -178,11 +180,11 @@ class PaypalHandler:
if sandbox_mode :
self.id = Environment.paypal_id_sandbox
self.key = Environment.paypal_key_sandbox
self.base_url = BASE_URL
self.base_url = BASE_URL_SANDBOX
else :
self.id = Environment.paypal_id_prod
self.key = Environment.paypal_key_prod
self.base_url = 'https://api-m.paypal.com'
self.base_url = BASE_URL_PROD
@@ -239,6 +241,11 @@ class PaypalHandler:
Returns:
dict | None: PayPal order response JSON, or None if failed.
"""
# Fetch details of order from mart database and compute total credits and price
order_request.load_items_and_price()
# Prepare payload for post request to paypal API
order_data = {
'intent': 'CAPTURE',
'purchase_units': [
@@ -286,20 +293,24 @@ class PaypalHandler:
# order_id (key): json.loads(order_response.text)["id"]
# user_id : order_request.user_id
# created_at : order_request.created_at
# status : order_request.status
# status : PENDING
# basket (json) : OrderDetails.jsonify()
# total_price : order_request.total_price
# currency : order_request.currency
# updated_at : order_request.created_at
# Now we can increment the supabase balance by so many credits as in the balance.
#TODO still
# Create a cache item for credits to be granted to user
CreditCache.set_credits(
user_id = order_request.user_id,
order_id = json.loads(order_response.text)["id"],
credits_to_grant = order_request.total_credits)
return order_response.json()
# Standalone function to capture a payment
def capture(self, order_id: str):
def capture(self, user_id: str, order_id: str):
"""
Captures payment for a PayPal order.
@@ -314,7 +325,7 @@ class PaypalHandler:
try:
capture_response = requests.post(
url = f'{BASE_URL}/v2/checkout/orders/{order_id}/capture',
url = f'{self.base_url}/v2/checkout/orders/{order_id}/capture',
headers = {'Authorization': f'Bearer {access_token}'},
json = {},
)
@@ -322,11 +333,12 @@ class PaypalHandler:
logger.error(f'Error while requesting access token: {exc}')
return None
# Raise exception if API call failed
capture_response.raise_for_status()
# todo check status code + try except
print(capture_response.text)
# order_id = json.loads(response.text)["id"]
# print(capture_response.text)
# TODO: update status to PAID in sql database

View File

@@ -1,14 +1,24 @@
import logging
from typing import Literal
from fastapi import FastAPI, HTTPException
from ..payments import PaypalHandler, OrderRequest
from fastapi import APIRouter, HTTPException
from ..payments import PaypalClient, OrderRequest
from ..supabase.supabase import SupabaseClient
from ..cache import CreditCache, make_credit_cache_key
app = FastAPI()
# Initialize PayPal handler
paypal_handler = PaypalHandler(sandbox_mode=True)
# Create a PayPal & Supabase client
paypal_client = PaypalClient(sandbox_mode=False)
supabase = SupabaseClient()
@app.post("/orders/new")
# Initialize the API router
router = APIRouter()
# Initialize the logger
logger = logging.getLogger(__name__)
@router.post("/orders/new")
def create_order(
user_id: str,
basket: list,
@@ -34,12 +44,12 @@ def create_order(
)
# Process the order and return the details
return paypal_handler.order(order_request = order)
return paypal_client.order(order_request = order)
@app.post("/orders/{order_id}/capture")
def capture_order(order_id: str):
@router.post("/orders/{order_id}/{user_id}capture")
def capture_order(order_id: str, user_id: str):
"""
Captures payment for an existing PayPal order.
@@ -49,7 +59,20 @@ def capture_order(order_id: str):
Returns:
dict: The PayPal capture response.
"""
result = paypal_handler.capture(order_id)
# Capture the payment
result = paypal_client.capture(order_id)
# Grant the user the correct amount of credits:
credits = CreditCache.get_credits(user_id, order_id)
if credits:
supabase.increment_credit_balance(
user_id=user_id,
amount=credits
)
logger.info('Payment capture succeeded: incrementing balance of user {user_id} by {credits}.')
else:
logger.error('Capture payment failed. Could not find cache key for user {user_id} and order {order_id}')
return result

View File

@@ -1,4 +0,0 @@
"""This module contains the descriptions of items to be purchased in the AnyWay store."""

View File

@@ -0,0 +1,46 @@
"""Collection of tests to ensure correct implementation and track progress of paypal payments."""
from fastapi.testclient import TestClient
import pytest
from ..main import app
@pytest.fixture(scope="module")
def client():
"""Client used to call the app."""
return TestClient(app)
@pytest.mark.parametrize(
"location,status_code",
[
([45.7576485, 4.8330241], 200), # Lyon, France
([41.4020572, 2.1818985], 200), # Barcelona, Spain
([59.3293, 18.0686], 200), # Stockholm, Sweden
([43.6532, -79.3832], 200), # Toronto, Canada
([38.7223, -9.1393], 200), # Lisbon, Portugal
([6.5244, 3.3792], 200), # Lagos, Nigeria
([17.3850, 78.4867], 200), # Hyderabad, India
([30.0444, 31.2357], 200), # Cairo, Egypt
([50.8503, 4.3517], 200), # Brussels, Belgium
([35.2271, -80.8431], 200), # Charlotte, USA
([10.4806, -66.9036], 200), # Caracas, Venezuela
([9.51074, -13.71118], 200), # Conakry, Guinea
]
)
def test_nearby(client, location, status_code): # pylint: disable=redefined-outer-name
"""
Test n°1 : Verify handling of invalid input.
Args:
client:
request:
"""
response = client.post(f"/get-nearby/landmarks/{location[0]}/{location[1]}")
suggestions = response.json()
# checks :
assert response.status_code == status_code # check for successful planning
assert isinstance(suggestions, list) # check that the return type is a list
assert len(suggestions) > 0