strong base for payment handling
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Failing after 50s
Build and deploy the backend to staging / Deploy to staging (pull_request) Has been skipped
Run testing on the backend code / Build (pull_request) Failing after 2m32s
Run linting on the backend code / Build (pull_request) Successful in 2m39s
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Failing after 50s
Build and deploy the backend to staging / Deploy to staging (pull_request) Has been skipped
Run testing on the backend code / Build (pull_request) Failing after 2m32s
Run linting on the backend code / Build (pull_request) Successful in 2m39s
This commit is contained in:
@@ -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
|
||||
|
@@ -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"}
|
||||
|
||||
|
@@ -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)
|
||||
)
|
||||
|
Reference in New Issue
Block a user