Merge modifications for more separate backend functions #69

Open
kscheidecker wants to merge 39 commits from backend/micro-services-restructuring into main
3 changed files with 395 additions and 150 deletions
Showing only changes of commit ab03cee3e3 - Show all commits

View File

@@ -1,149 +1,336 @@
from typing import Literal
from datetime import datetime, timedelta
import logging
import json
from pydantic import BaseModel
from fastapi import HTTPException
from pydantic import BaseModel, Field, field_validator
import requests
from ..configuration.environment import Environment
# Model for payment request body
class OrderDetails():
# 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__)
class BasketItem(BaseModel):
"""
Represents a single item in the user's basket.
Attributes:
id (str): The unique identifier for the item.
quantity (int): The number of units of the item.
"""
id: str
quantity: int
class Item(BaseModel):
"""
Represents an item available in the shop.
Attributes:
id (str): The unique identifier for the item.
name (str): The name of the item.
description (str): The description of the item.
unit_price (str): The unit price of the item.
"""
id: str
name: str
description: str
unit_price: str
def item_from_sql(item_id: str):
"""
Fetches an item from the database by its ID.
Args:
item_id (str): The unique identifier for the item.
Returns:
Item: The item object retrieved from the database.
remoll marked this conversation as resolved Outdated

Pretty sure that won't work. It should definitely not be hardcoded since the callback will change depending on prod or staging.

Pretty sure that won't work. It should definitely not be hardcoded since the callback will change depending on prod or staging.
"""
# TODO: Replace with actual SQL fetch logic
return Item(
id = '12345678',
name = 'test_item',
description = 'lorem ipsum',
unit_price = 420
)
class OrderRequest(BaseModel):
"""
Represents an order request from the frontend.
Attributes:
user_id (str): The ID of the user placing the order.
basket (list[BasketItem]): List of basket items.
currency (str): The currency code for the order.
created_at (datetime): Timestamp when the order was created.
updated_at (datetime): Timestamp when the order was last updated.
items (list[Item]): List of item details loaded from the database.
total_price (float): Total price of the order.
"""
user_id: str
number_of_credits: Literal[10, 50, 100]
unit_price: float
amount: int
currency: Literal['USD', 'EUR', 'CHF']
basket: list[BasketItem]
currency: Literal['CHF', 'EUR', 'USD']
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
@field_validator('basket')
def validate_basket(cls, v):
"""Validates the basket items.
Args:
v (list): List of basket items.
Raises:
ValueError: If basket does not contain valid BasketItem objects.
Returns:
list: The validated basket.
"""
if not v or not all(isinstance(i, BasketItem) for i in v):
raise ValueError('Basket must contain BasketItem objects')
return
def load_items_and_price(self):
"""
Loads item details from database and calculates the total price.
"""
self.items = []
self.total_price = 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
def to_paypal_items(self):
"""
Converts items to the PayPal API item format.
Returns:
list: List of items formatted for PayPal API.
"""
item_list = []
for basket_item, item in zip(self.basket, self.items):
item_list.append({
'id': item.id,
'name': item.name,
'description': item.description,
'quantity': str(basket_item.quantity),
'unit_amount': {
'currency_code': self.currency,
'value': str(item.unit_price)
}
})
return item_list
# Payment handler class for managing PayPal payments
class PaypalHandler:
"""
Handles PayPal payment operations.
# PayPal secrets
username = Environment.paypal_id_sandbox
password = Environment.paypal_key_sandbox
Attributes:
sandbox (bool): Whether to use the sandbox environment.
id (str): PayPal client ID.
key (str): PayPal client secret.
base_url (str): Base URL for PayPal API.
_token_cache (dict): Cache for the PayPal OAuth access token.
"""
_token_cache = {
"access_token": None,
"expires_at": 0
}
order_request = None
def __init__(
self,
order_details: OrderDetails,
sandbox_mode: bool = False
):
# Initialize the logger
self.logger = logging.getLogger(__name__)
"""
Initializes the handler.
# Payment request parameters
self.order_details = order_details
Args:
sandbox_mode (bool): Whether to use sandbox credentials.
"""
self.logger = logging.getLogger(__name__)
self.sandbox = sandbox_mode
# Only support purchase of credit 'bundles': 10, 50 or 100 credits worth of trip generation
# def fetch_price(self) -> float:
# '''
# Fetches the price of credits in the specified currency.
# '''
# result = self.supabase.table('prices').select('credit_amount').eq('currency', self.details.currency).single().execute()
# if result.data:
# return result.data.get('price')
# else:
# self.logger.error(f'Unsupported currency: {self.details.currency}')
# return None
def validate(self):
if self.sandbox :
validation_url = 'https://api-m.sandbox.paypal.com/v1/oauth2/token'
# PayPal keys
if sandbox_mode :
self.id = Environment.paypal_id_sandbox
self.key = Environment.paypal_key_sandbox
self.base_url = BASE_URL
else :
validation_url = 'https://api-m.paypal.com/v1/oauth2/token'
self.id = Environment.paypal_id_prod
self.key = Environment.paypal_key_prod
self.base_url = 'https://api-m.paypal.com'
# payload for the validation request
def _get_access_token(self) -> str | None:
"""
Gets (and caches) a PayPal access token.
Returns:
str | None: The access token if successful, None otherwise.
"""
now = datetime.now()
# Check if token is still valid
if (
self._token_cache["access_token"] is not None
and self._token_cache["expires_at"] > now
):
self.logger.info('Returning (cached) access token.')
return self._token_cache["access_token"]
# Request new token
validation_data = {'grant_type': 'client_credentials'}
try:
# pass the request
validation_response = requests.post(
url=validation_url,
data=validation_data,
auth=(self.username, self.password)
url = f'{self.base_url}/v1/oauth2/token',
data = validation_data,
auth =(self.id, self.key)
)
except Exception as exc:
self.logger.error(f'Error while requesting access token: {exc}')
return None
if validation_response.status_code == 201 :
access_token = json.loads(validation_response.text)['access_token']
self.logger.info('Validation step successful. Returning access token.')
return access_token
self.logger.error(f'Error {validation_response.status_code} while requesting access token: {validation_response.text}')
return None
data = validation_response.json()
access_token = data.get("access_token")
expires_in = int(data.get("expires_in", 3600)) # seconds, default 1 hour
# Cache the token and its expiry
self._token_cache["access_token"] = access_token
self._token_cache["expires_at"] = now + timedelta(seconds=expires_in - 60) # buffer 1 min
self.logger.info('Returning (new) access token.')
return access_token
def order(
self,
access_token: int
):
def order(self, order_request: OrderRequest):
"""
Creates a new PayPal order.
if self.sandbox :
order_url = 'https://api-m.sandbox.paypal.com/v2/checkout/orders'
else :
order_url = 'https://api-m.paypal.com/v2/checkout/orders'
Args:
order_request (OrderRequest): The order request.
# payload for the order equest
Returns:
dict | None: PayPal order response JSON, or None if failed.
"""
order_data = {
'intent': 'CAPTURE',
'purchase_units': [
{
'items': [
{
'name': f'{self.order_details.number_of_credits} Credits Pack',
'description': f'Credits for {self.order_details.number_of_credits} trip generations on AnyWay.',
'quantity': self.order_details.amount,
'unit_amount': {
'currency_code': self.order_details.currency,
'value': self.order_details.unit_price
}
}
],
'items': order_request.to_paypal_items(),
'amount': {
'currency_code': self.order_details.currency,
'value': self.order_details.amount*self.order_details.unit_price,
# 'breakdown': {
# 'item_total': {
# 'currency_code': 'CHF',
# 'value': '5.00'
# }
# }
## what is that for ?
'currency_code': order_request.currency,
'value': str(order_request.total_price),
'breakdown': {
'item_total': {
'currency_code': order_request.currency,
'value': str(order_request.total_price)
}
}
}
}
],
# TODO: add these to anydev website somehow
# TODO: add these to anydev website
'application_context': {
'return_url': 'https://anydev.info',
'cancel_url': 'https://anydev.info'
}
}
# TODO continue here
# Get the access_token:
access_token = self._get_access_token()
try:
order_response = requests.post(
url = f'{self.base_url}/v2/checkout/orders',
headers = {'Authorization': f'Bearer {access_token}'},
json = order_data,
)
# Raise HTTP Exception if request was unsuccessful.
except Exception as exc:
self.logger.error(f'Error creating PayPal order: {exc}')
return None
order_response.raise_for_status()
# TODO Now that we have the order ID, we can inscribe the details in sql database using the order id given by paypal
# DB for storing the transactions:
# 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
# basket (json) : OrderDetails.jsonify()
# total_price : order_request.total_price
# currency : order_request.currency
# updated_at : order_request.created_at
return order_response.json()
pass
# Standalone function to capture a payment
def capture(self, order_id: str):
"""
Captures payment for a PayPal order.
def capture(self):
pass
Args:
order_id (str): The PayPal order ID.
Returns:
dict | None: PayPal capture response JSON, or None if failed.
"""
# Get the access_token:
access_token = self._get_access_token()
try:
capture_response = requests.post(
url = f'{BASE_URL}/v2/checkout/orders/{order_id}/capture',
headers = {'Authorization': f'Bearer {access_token}'},
json = {},
)
except Exception as exc:
logger.error(f'Error while requesting access token: {exc}')
return None
capture_response.raise_for_status()
# todo check status code + try except
print(capture_response.text)
# order_id = json.loads(response.text)["id"]
# TODO: update status to PAID in sql database
# where order_id (key) = order_id
# status : 'PAID'
# updated_at : datetime.now()
# Not sure yet if/how to implement that
def cancel(self):
pass

View File

@@ -1,79 +1,136 @@
import logging
import paypalrestsdk
from fastapi import HTTPException, APIRouter
from typing import Literal
from ..supabase.supabase import SupabaseClient
from .payment_handler import PaymentRequest, PaymentHandler
from fastapi import FastAPI, HTTPException
from ..payments import PaypalHandler, OrderRequest
# Set up logging and supabase
logger = logging.getLogger(__name__)
supabase = SupabaseClient()
app = FastAPI()
# Configure PayPal SDK
paypalrestsdk.configure({
"mode": "sandbox", # Use 'live' for production
"client_id": "YOUR_PAYPAL_CLIENT_ID",
"client_secret": "YOUR_PAYPAL_SECRET"
})
# Initialize PayPal handler
paypal_handler = PaypalHandler(sandbox_mode=True)
# Define the API router
router = APIRouter()
@router.post("/purchase/credits")
def purchase_credits(payment_request: PaymentRequest):
@app.post("/orders/new")
def create_order(
user_id: str,
basket: list,
currency: Literal['CHF', 'EUR', 'USD']
):
"""
Handles token purchases. Calculates the number of tokens based on the amount paid,
updates the user's balance, and processes PayPal payment.
Creates a new PayPal order.
Args:
user_id (str): The ID of the user placing the order.
basket (list): The basket items.
currency (str): The currency code.
Returns:
dict: The PayPal order details.
"""
payment_handler = PaymentHandler(payment_request)
# Create PayPal payment and get the approval URL
approval_url = payment_handler.create_paypal_payment()
# Create order :
order = OrderRequest(
user_id = user_id,
basket=basket,
currency=currency
)
return {
"message": "Purchase initiated successfully",
"payment_id": payment_handler.payment_id,
"credits": payment_request.credit_amount,
"approval_url": approval_url,
}
# Process the order and return the details
return paypal_handler.order(order_request = order)
@router.get("/payment/success")
def payment_success(paymentId: str, PayerID: str):
@app.post("/orders/{order_id}/capture")
def capture_order(order_id: str):
"""
Handles successful PayPal payment.
Captures payment for an existing PayPal order.
Args:
order_id (str): The PayPal order ID.
Returns:
dict: The PayPal capture response.
"""
payment = paypalrestsdk.Payment.find(paymentId)
if payment.execute({"payer_id": PayerID}):
logger.info("Payment executed successfully")
# Retrieve transaction details from the database
result = supabase.table("pending_payments").select("*").eq("payment_id", paymentId).single().execute()
if not result.data:
raise HTTPException(status_code=404, detail="Transaction not found")
# Extract the necessary information
user_id = result.data["user_id"]
credit_amount = result.data["credit_amount"]
# Update the user's balance
supabase.increment_credit_balance(user_id, amount=credit_amount)
# Optionally, delete the pending payment entry since the transaction is completed
supabase.table("pending_payments").delete().eq("payment_id", paymentId).execute()
return {"message": "Payment completed successfully"}
else:
logger.error(f"Payment execution failed: {payment.error}")
raise HTTPException(status_code=500, detail="Payment execution failed")
result = paypal_handler.capture(order_id)
return result
@router.get("/payment/cancel")
def payment_cancel():
"""
Handles PayPal payment cancellation.
"""
return {"message": "Payment was cancelled"}
# import logging
# import paypalrestsdk
# from fastapi import HTTPException, APIRouter
# from ..supabase.supabase import SupabaseClient
# from .payment_handler import PaymentRequest, PaymentHandler
# # Set up logging and supabase
# logger = logging.getLogger(__name__)
# supabase = SupabaseClient()
# # Configure PayPal SDK
# paypalrestsdk.configure({
# "mode": "sandbox", # Use 'live' for production
# "client_id": "YOUR_PAYPAL_CLIENT_ID",
# "client_secret": "YOUR_PAYPAL_SECRET"
# })
# # Define the API router
# router = APIRouter()
# @router.post("/purchase/credits")
# def purchase_credits(payment_request: PaymentRequest):
# """
# Handles token purchases. Calculates the number of tokens based on the amount paid,
# updates the user's balance, and processes PayPal payment.
# """
# payment_handler = PaymentHandler(payment_request)
# # Create PayPal payment and get the approval URL
# approval_url = payment_handler.create_paypal_payment()
# return {
# "message": "Purchase initiated successfully",
# "payment_id": payment_handler.payment_id,
# "credits": payment_request.credit_amount,
# "approval_url": approval_url,
# }
# @router.get("/payment/success")
# def payment_success(paymentId: str, PayerID: str):
# """
# Handles successful PayPal payment.
# """
# payment = paypalrestsdk.Payment.find(paymentId)
# if payment.execute({"payer_id": PayerID}):
# logger.info("Payment executed successfully")
# # Retrieve transaction details from the database
# result = supabase.table("pending_payments").select("*").eq("payment_id", paymentId).single().execute()
# if not result.data:
# raise HTTPException(status_code=404, detail="Transaction not found")
# # Extract the necessary information
# user_id = result.data["user_id"]
# credit_amount = result.data["credit_amount"]
# # Update the user's balance
# supabase.increment_credit_balance(user_id, amount=credit_amount)
# # Optionally, delete the pending payment entry since the transaction is completed
# supabase.table("pending_payments").delete().eq("payment_id", paymentId).execute()
# return {"message": "Payment completed successfully"}
# else:
# logger.error(f"Payment execution failed: {payment.error}")
# raise HTTPException(status_code=500, detail="Payment execution failed")
# @router.get("/payment/cancel")
# def payment_cancel():
# """
# Handles PayPal payment cancellation.
# """
# return {"message": "Payment was cancelled"}

View File

@@ -74,6 +74,7 @@ order_data = {
order_response = requests.post(
url=order_url,
headers={"Authorization": f"Bearer {access_token}"}, ## need access token here?
json=order_data,
auth=(username, password)
)