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
354 lines
11 KiB
Python
354 lines
11 KiB
Python
import json
|
|
import logging
|
|
from typing import Literal
|
|
from datetime import datetime, timedelta
|
|
|
|
import requests
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
from ..configuration.environment import Environment
|
|
from ..cache import CreditCache, make_credit_cache_key
|
|
|
|
|
|
# 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):
|
|
"""
|
|
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 (float): The unit price of the item.
|
|
"""
|
|
id: str
|
|
name: str
|
|
description: str
|
|
unit_price: float
|
|
unit_credits: int
|
|
|
|
|
|
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 = 0.1,
|
|
unit_credits = 5
|
|
)
|
|
|
|
|
|
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
|
|
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 = None
|
|
total_credits: int = None
|
|
|
|
@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):
|
|
# This should be automatic upon initialization of the class
|
|
"""
|
|
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 # increment price
|
|
self.total_credits += item.unit_credits * basket_item.quantity # increment credit balance
|
|
|
|
|
|
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 PaypalClient:
|
|
"""
|
|
Handles PayPal payment operations.
|
|
|
|
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
|
|
}
|
|
|
|
|
|
def __init__(
|
|
self,
|
|
sandbox_mode: bool = False
|
|
):
|
|
"""
|
|
Initializes the handler.
|
|
|
|
Args:
|
|
sandbox_mode (bool): Whether to use sandbox credentials.
|
|
"""
|
|
self.logger = logging.getLogger(__name__)
|
|
self.sandbox = sandbox_mode
|
|
|
|
# PayPal keys
|
|
if sandbox_mode :
|
|
self.id = Environment.paypal_id_sandbox
|
|
self.key = Environment.paypal_key_sandbox
|
|
self.base_url = BASE_URL_SANDBOX
|
|
else :
|
|
self.id = Environment.paypal_id_prod
|
|
self.key = Environment.paypal_key_prod
|
|
self.base_url = BASE_URL_PROD
|
|
|
|
|
|
|
|
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 = 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
|
|
|
|
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, order_request: OrderRequest):
|
|
"""
|
|
Creates a new PayPal order.
|
|
|
|
Args:
|
|
order_request (OrderRequest): The order request.
|
|
|
|
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': [
|
|
{
|
|
'items': order_request.to_paypal_items(),
|
|
'amount': {
|
|
'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
|
|
'application_context': {
|
|
'return_url': 'https://anydev.info',
|
|
'cancel_url': 'https://anydev.info'
|
|
}
|
|
}
|
|
|
|
# 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 : PENDING
|
|
# basket (json) : OrderDetails.jsonify()
|
|
# total_price : order_request.total_price
|
|
# currency : order_request.currency
|
|
# updated_at : order_request.created_at
|
|
|
|
# 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, user_id: str, order_id: str):
|
|
"""
|
|
Captures payment for a PayPal order.
|
|
|
|
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'{self.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
|
|
|
|
# Raise exception if API call failed
|
|
capture_response.raise_for_status()
|
|
|
|
|
|
|
|
# print(capture_response.text)
|
|
|
|
# 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
|