From ab03cee3e38f2ef8aaf5bee2ec3e69039680f775 Mon Sep 17 00:00:00 2001 From: kilian Date: Wed, 8 Oct 2025 17:30:07 +0200 Subject: [PATCH] strong base for payment handling --- backend/src/payments/payment_handler.py | 359 ++++++++++++++++++------ backend/src/payments/payment_router.py | 185 +++++++----- backend/src/payments/test.py | 1 + 3 files changed, 395 insertions(+), 150 deletions(-) diff --git a/backend/src/payments/payment_handler.py b/backend/src/payments/payment_handler.py index 653cf8b..0981de4 100644 --- a/backend/src/payments/payment_handler.py +++ b/backend/src/payments/payment_handler.py @@ -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. + """ + # 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 diff --git a/backend/src/payments/payment_router.py b/backend/src/payments/payment_router.py index 9b8450a..e1e8b46 100644 --- a/backend/src/payments/payment_router.py +++ b/backend/src/payments/payment_router.py @@ -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"} diff --git a/backend/src/payments/test.py b/backend/src/payments/test.py index 6950fd6..383e6af 100644 --- a/backend/src/payments/test.py +++ b/backend/src/payments/test.py @@ -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) )